Hugoifier

hugoifier / hugoifier / utils / complete.py
Blame History Raw 378 lines
1
"""
2
Full end-to-end pipeline: detect → copy → configure → decap.
3
4
For already-Hugo themes: assembles a clean, standalone site.
5
For raw HTML themes: calls hugoify first, then assembles.
6
"""
7
8
import logging
9
import os
10
import shutil
11
from pathlib import Path
12
13
from .decapify import decapify
14
from .hugoify import hugoify_html, hugoify_nextjs
15
from .theme_finder import find_hugo_theme, find_nextjs_app, find_raw_html_files
16
from .theme_patcher import patch_config, patch_theme
17
18
19
def complete(
20
input_path: str,
21
output_dir: str = None,
22
cms_name: str = None,
23
cms_logo: str = None,
24
cms_color: str = None,
25
) -> str:
26
"""
27
Run the full pipeline for a theme.
28
29
Args:
30
input_path: Path to a theme directory (from themes/) or raw HTML dir.
31
output_dir: Where to write the output site. Defaults to output/{theme-name}.
32
cms_name: Whitelabel CMS name for Decap admin UI.
33
cms_logo: Whitelabel logo URL for Decap admin UI.
34
cms_color: Whitelabel top-bar color for Decap admin UI.
35
36
Returns:
37
Path to the generated site, or error message.
38
"""
39
logging.info(f"Starting pipeline for {input_path} ...")
40
41
branding = {'cms_name': cms_name, 'cms_logo': cms_logo, 'cms_color': cms_color}
42
info = find_hugo_theme(input_path)
43
44
if info:
45
return _assemble_hugo_site(info, output_dir, branding)
46
47
# Next.js path (check before raw HTML since Next.js projects may contain .html files)
48
nextjs_info = find_nextjs_app(input_path)
49
if nextjs_info:
50
return _convert_nextjs(input_path, nextjs_info, output_dir, branding)
51
52
# Raw HTML path
53
html_files = find_raw_html_files(input_path)
54
if not html_files:
55
raise ValueError(f"No Hugo theme, Next.js app, or HTML files found in {input_path}")
56
return _convert_raw_html(input_path, html_files, output_dir, branding)
57
58
59
# ---------------------------------------------------------------------------
60
# Hugo theme path
61
# ---------------------------------------------------------------------------
62
63
def _assemble_hugo_site(info: dict, output_dir: str = None, branding: dict = None) -> str:
64
theme_dir = info['theme_dir']
65
example_site = info['example_site']
66
theme_name = info['theme_name']
67
68
if output_dir is None:
69
output_dir = str(Path(__file__).parents[2] / 'output' / theme_name)
70
71
logging.info(f"Building site at {output_dir} ...")
72
os.makedirs(output_dir, exist_ok=True)
73
74
# 1. Copy theme files → themes/{theme_name}/
75
dest_theme = os.path.join(output_dir, 'themes', theme_name)
76
_copy_dir(theme_dir, dest_theme, exclude={'exampleSite', '__MACOSX', '.DS_Store'})
77
logging.info(f"Copied theme to {dest_theme}")
78
patch_theme(dest_theme)
79
80
# 2. Copy exampleSite content/static/data → site root
81
if example_site:
82
for subdir in ('content', 'data', 'i18n'):
83
src = os.path.join(example_site, subdir)
84
if os.path.isdir(src):
85
_copy_dir(src, os.path.join(output_dir, subdir))
86
logging.info(f"Copied {subdir}/ from exampleSite")
87
88
# Static: merge exampleSite/static into output/static
89
src_static = os.path.join(example_site, 'static')
90
if os.path.isdir(src_static):
91
_copy_dir(src_static, os.path.join(output_dir, 'static'))
92
logging.info("Copied static/ from exampleSite")
93
94
# Write hugo.toml from exampleSite config
95
config_toml = _find_config(example_site)
96
if config_toml:
97
_write_hugo_toml(config_toml, output_dir, theme_name)
98
else:
99
_write_minimal_hugo_toml(output_dir, theme_name)
100
else:
101
_write_minimal_hugo_toml(output_dir, theme_name)
102
# Create minimal content/_index.md
103
content_dir = os.path.join(output_dir, 'content')
104
os.makedirs(content_dir, exist_ok=True)
105
index_md = os.path.join(content_dir, '_index.md')
106
if not os.path.exists(index_md):
107
with open(index_md, 'w') as f:
108
f.write('---\ntitle: Home\n---\n')
109
110
# 3. Generate Decap CMS config
111
b = branding or {}
112
decapify(
113
output_dir,
114
cms_name=b.get('cms_name'), cms_logo=b.get('cms_logo'), cms_color=b.get('cms_color'),
115
)
116
117
logging.info(f"Done. Site ready at: {output_dir}")
118
logging.info(f"Run: cd {output_dir} && hugo serve")
119
return output_dir
120
121
122
# ---------------------------------------------------------------------------
123
# Next.js path
124
# ---------------------------------------------------------------------------
125
126
def _convert_nextjs(
127
input_path: str, nextjs_info: dict, output_dir: str = None, branding: dict = None
128
) -> str:
129
app_dir = nextjs_info['app_dir']
130
theme_name = nextjs_info.get('app_name', os.path.basename(os.path.abspath(input_path)))
131
132
if output_dir is None:
133
output_dir = str(Path(__file__).parents[2] / 'output' / theme_name)
134
135
logging.info(f"Converting Next.js app: {theme_name}")
136
137
# Convert: capture rendered HTML if dev server running, else AI fallback
138
hugo_layouts = hugoify_nextjs(nextjs_info)
139
140
os.makedirs(output_dir, exist_ok=True)
141
142
# Extract captured CSS if present (from rendered HTML capture)
143
captured_css = hugo_layouts.pop('_captured_css', {})
144
145
# Write converted layouts
146
theme_layouts_dir = os.path.join(output_dir, 'themes', theme_name, 'layouts')
147
os.makedirs(os.path.join(theme_layouts_dir, '_default'), exist_ok=True)
148
os.makedirs(os.path.join(theme_layouts_dir, 'partials'), exist_ok=True)
149
150
for filename, content in hugo_layouts.items():
151
# Fix common AI mistake: partial "partials/X.html" → partial "X.html"
152
if isinstance(content, str):
153
content = content.replace('partial "partials/', 'partial "')
154
dest = os.path.join(theme_layouts_dir, filename)
155
os.makedirs(os.path.dirname(dest), exist_ok=True)
156
with open(dest, 'w') as f:
157
f.write(content)
158
159
# Copy public/ assets to theme static/
160
public_dir = os.path.join(app_dir, 'public')
161
theme_static = os.path.join(output_dir, 'themes', theme_name, 'static')
162
if os.path.isdir(public_dir):
163
_copy_dir(public_dir, theme_static)
164
logging.info("Copied public/ assets to static/")
165
166
# Write captured CSS (from rendered HTML capture)
167
css_dest = os.path.join(theme_static, 'css')
168
os.makedirs(css_dest, exist_ok=True)
169
for css_name, css_content in captured_css.items():
170
with open(os.path.join(css_dest, css_name), 'w') as f:
171
f.write(css_content)
172
logging.info(f"Wrote captured CSS: {css_name}")
173
174
# Also copy source CSS files (globals.css etc.)
175
for css_file in nextjs_info.get('css_files', []):
176
if os.path.isfile(css_file):
177
shutil.copy2(css_file, os.path.join(css_dest, os.path.basename(css_file)))
178
logging.info("Copied CSS files")
179
180
_write_minimal_hugo_toml(output_dir, theme_name)
181
_write_default_layouts(output_dir, theme_name)
182
183
# Create minimal content
184
content_dir = os.path.join(output_dir, 'content')
185
os.makedirs(content_dir, exist_ok=True)
186
with open(os.path.join(content_dir, '_index.md'), 'w') as f:
187
f.write('---\ntitle: Home\n---\n')
188
189
b = branding or {}
190
decapify(
191
output_dir,
192
cms_name=b.get('cms_name'), cms_logo=b.get('cms_logo'), cms_color=b.get('cms_color'),
193
)
194
195
logging.info(f"Done. Site ready at: {output_dir}")
196
logging.info(f"Run: cd {output_dir} && hugo serve")
197
return output_dir
198
199
200
# ---------------------------------------------------------------------------
201
# Raw HTML path
202
# ---------------------------------------------------------------------------
203
204
def _convert_raw_html(
205
input_path: str, html_files: list, output_dir: str = None, branding: dict = None
206
) -> str:
207
theme_name = os.path.basename(os.path.abspath(input_path))
208
209
if output_dir is None:
210
output_dir = str(Path(__file__).parents[2] / 'output' / theme_name)
211
212
logging.info(f"Converting raw HTML theme: {theme_name}")
213
214
# Direct HTML extraction — use the actual HTML as-is, no AI reinterpretation
215
main_html = _pick_main_html(html_files)
216
logging.info(f"Extracting {main_html} ...")
217
hugo_layouts = hugoify_html(main_html)
218
219
os.makedirs(output_dir, exist_ok=True)
220
221
# Write converted layouts
222
theme_layouts_dir = os.path.join(output_dir, 'themes', theme_name, 'layouts')
223
os.makedirs(os.path.join(theme_layouts_dir, '_default'), exist_ok=True)
224
os.makedirs(os.path.join(theme_layouts_dir, 'partials'), exist_ok=True)
225
226
for filename, content in hugo_layouts.items():
227
dest = os.path.join(theme_layouts_dir, filename)
228
os.makedirs(os.path.dirname(dest), exist_ok=True)
229
with open(dest, 'w') as f:
230
f.write(content)
231
232
# Copy ALL static assets from the HTML theme directory
233
theme_static = os.path.join(output_dir, 'themes', theme_name, 'static')
234
for item in os.listdir(input_path):
235
src = os.path.join(input_path, item)
236
if os.path.isdir(src) and item not in ('__MACOSX', '.git', 'node_modules'):
237
_copy_dir(src, os.path.join(theme_static, item))
238
elif os.path.isfile(src) and not item.endswith('.html'):
239
# Copy non-HTML files (images, fonts, etc.) to static root
240
os.makedirs(theme_static, exist_ok=True)
241
shutil.copy2(src, os.path.join(theme_static, item))
242
243
_write_minimal_hugo_toml(output_dir, theme_name)
244
_write_default_layouts(output_dir, theme_name)
245
246
# Create minimal content
247
content_dir = os.path.join(output_dir, 'content')
248
os.makedirs(content_dir, exist_ok=True)
249
with open(os.path.join(content_dir, '_index.md'), 'w') as f:
250
f.write('---\ntitle: Home\n---\n')
251
252
b = branding or {}
253
decapify(
254
output_dir,
255
cms_name=b.get('cms_name'), cms_logo=b.get('cms_logo'), cms_color=b.get('cms_color'),
256
)
257
258
logging.info(f"Done. Site ready at: {output_dir}")
259
return output_dir
260
261
262
# ---------------------------------------------------------------------------
263
# Helpers
264
# ---------------------------------------------------------------------------
265
266
def _copy_dir(src: str, dest: str, exclude: set = None):
267
"""Copy src → dest, skipping excluded names."""
268
exclude = exclude or set()
269
if not os.path.isdir(src):
270
return
271
os.makedirs(dest, exist_ok=True)
272
for item in os.listdir(src):
273
if item in exclude or item.startswith('._'):
274
continue
275
s = os.path.join(src, item)
276
d = os.path.join(dest, item)
277
if os.path.isdir(s):
278
_copy_dir(s, d, exclude)
279
else:
280
shutil.copy2(s, d)
281
282
283
def _find_config(example_site: str) -> str | None:
284
"""Find config.toml or hugo.toml in exampleSite."""
285
for name in ('hugo.toml', 'config.toml'):
286
p = os.path.join(example_site, name)
287
if os.path.exists(p):
288
return p
289
# config/_default/config.toml pattern
290
p = os.path.join(example_site, 'config', '_default', 'config.toml')
291
if os.path.exists(p):
292
return p
293
return None
294
295
296
def _write_hugo_toml(source_config: str, output_dir: str, theme_name: str):
297
"""Copy source config to hugo.toml, ensuring theme = theme_name and modern key names."""
298
import re
299
with open(source_config, 'r') as f:
300
content = f.read()
301
302
# Suppress noisy but harmless warnings from example content
303
if 'ignoreLogs' not in content:
304
content += "\nignorelogs = ['warning-goldmark-raw-html']\n"
305
306
# Ensure theme is set correctly
307
if re.search(r'^theme\s*=', content, re.MULTILINE):
308
content = re.sub(r'^theme\s*=.*$', f'theme = "{theme_name}"', content, flags=re.MULTILINE)
309
else:
310
content = f'theme = "{theme_name}"\n' + content
311
312
dest = os.path.join(output_dir, 'hugo.toml')
313
with open(dest, 'w') as f:
314
f.write(content)
315
patch_config(dest)
316
logging.info("Wrote hugo.toml")
317
318
319
def _write_minimal_hugo_toml(output_dir: str, theme_name: str):
320
dest = os.path.join(output_dir, 'hugo.toml')
321
safe_name = theme_name.replace('"', '')
322
title = safe_name.replace('-', ' ').title()
323
with open(dest, 'w') as f:
324
f.write(f'''baseURL = "http://localhost:1313/"
325
languageCode = "en-us"
326
title = "{title}"
327
theme = "{safe_name}"
328
''')
329
logging.info("Wrote minimal hugo.toml")
330
331
332
def _write_default_layouts(output_dir: str, theme_name: str):
333
"""Write single.html and list.html if they don't already exist."""
334
layouts_dir = os.path.join(output_dir, 'themes', theme_name, 'layouts', '_default')
335
os.makedirs(layouts_dir, exist_ok=True)
336
337
single = os.path.join(layouts_dir, 'single.html')
338
if not os.path.exists(single):
339
with open(single, 'w') as f:
340
f.write('''{{ define "main" }}
341
<div style="max-width:48rem;margin:0 auto;padding:3rem 1.5rem">
342
<a href="/" style="color:#515be3;font-size:0.875rem;display:inline-block;margin-bottom:2rem">&larr; Back to home</a>
343
<h1 style="font-size:2.25rem;font-weight:700;margin-bottom:1rem">{{ .Title }}</h1>
344
{{ with .Params.description }}<p style="color:#9ca3af;font-size:1.125rem;margin-bottom:1.5rem">{{ . }}</p>{{ end }}
345
{{ with .Date }}<time style="color:#6b7280;font-size:0.875rem;display:block;margin-bottom:2.5rem">{{ .Format "January 2, 2006" }}</time>{{ end }}
346
<article>{{ .Content }}</article>
347
</div>
348
{{ end }}
349
''')
350
logging.info("Wrote default single.html")
351
352
list_html = os.path.join(layouts_dir, 'list.html')
353
if not os.path.exists(list_html):
354
with open(list_html, 'w') as f:
355
f.write('''{{ define "main" }}
356
<div style="max-width:48rem;margin:0 auto;padding:3rem 1.5rem">
357
<a href="/" style="color:#515be3;font-size:0.875rem;display:inline-block;margin-bottom:2rem">&larr; Back to home</a>
358
<h1 style="font-size:2.25rem;font-weight:700;margin-bottom:3rem">{{ .Title }}</h1>
359
{{ range .Pages }}
360
<a href="{{ .Permalink }}" style="display:block;padding:1.5rem;border-radius:1rem;border:1px solid #374151;margin-bottom:1.5rem;text-decoration:none;color:inherit">
361
<h2 style="font-size:1.25rem;font-weight:600;margin-bottom:0.5rem">{{ .Title }}</h2>
362
{{ with .Params.description }}<p style="color:#9ca3af">{{ . }}</p>{{ end }}
363
{{ with .Date }}<time style="color:#6b7280;font-size:0.875rem;margin-top:0.75rem;display:block">{{ .Format "January 2, 2006" }}</time>{{ end }}
364
</a>
365
{{ end }}
366
</div>
367
{{ end }}
368
''')
369
logging.info("Wrote default list.html")
370
371
372
def _pick_main_html(html_files: list) -> str:
373
"""Pick the most likely 'main' HTML file (index.html or first one)."""
374
for f in html_files:
375
if os.path.basename(f).lower() in ('index.html', 'home.html', 'main.html'):
376
return f
377
return html_files[0]
378

Keyboard Shortcuts

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