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