Skip to content

Commit 642ceb6

Browse files
cubeclaude
andcommitted
Multi-page portfolio with /work and /play pages
Expand single-page site into three pages: - / — hero landing - /work — about, skills, services, projects - /play — lately (reading/music/watching) + Glass.photo masonry gallery Refactored build.py for multi-page generation with shared template, cleaned and standardised all CSS (px units, design tokens), and updated CNAME for exp.thedataareclean.com hosting. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 4cef3fe commit 642ceb6

File tree

11 files changed

+2447
-518
lines changed

11 files changed

+2447
-518
lines changed

CNAME

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
thedataareclean.com
1+
exp.thedataareclean.com

build.py

Lines changed: 251 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@
33
"""Build script for portfolio site.
44
55
Reads markdown content files, renders them into HTML,
6-
and assembles the final dist/index.html.
6+
and assembles multi-page output: /, /work/, /play/.
77
88
Usage: python3 build.py
99
"""
1010

11+
import json
1112
import re
1213
import shutil
14+
import urllib.request
1315
from datetime import datetime
16+
from html import escape
1417
from pathlib import Path
1518

1619
BASE = Path(__file__).parent
@@ -152,7 +155,6 @@ def render_meta(md):
152155
url = items.get('url', '')
153156
image = items.get('image', '')
154157
twitter = items.get('twitter', '')
155-
lang = items.get('lang', 'en')
156158

157159
favicon = items.get('favicon', '')
158160
image_url = url.rstrip('/') + '/' + image if image and not image.startswith('http') else image
@@ -306,7 +308,7 @@ def render_projects(md):
306308
return (
307309
' <!-- PROJECTS -->\n'
308310
' <section class="projects-section">\n'
309-
' <h2 class="projects-title">\n'
311+
' <h2 class="section-title">\n'
310312
f' {title}\n'
311313
' </h2>\n'
312314
' <div class="projects">\n'
@@ -329,57 +331,255 @@ def render_lately(md):
329331
value = items.get(key, '\u2014')
330332
icon = icon_map[key]
331333
parts.append(
332-
' <div class="footer-lately-item">\n'
334+
' <div class="lately-item">\n'
333335
f' <span class="material-symbols-sharp">{icon}</span>\n'
334-
f' <span class="footer-lately-value">{value}</span>\n'
336+
f' <span class="lately-value">{value}</span>\n'
335337
' </div>'
336338
)
337339

338340
return (
339-
' <div class="footer-lately">\n'
341+
' <div class="lately-list">\n'
340342
+ '\n'.join(parts) + '\n'
341343
+ ' </div>'
342344
)
343345

344346

345-
def render_footer(footer_md, lately_md):
347+
def render_footer(footer_md):
346348
items = dict(parse_kv_list(footer_md))
347-
email = items.get('email', '')
348349
location = items.get('location', '')
349-
credit = parse_inline(items.get('credit', ''))
350+
designed = parse_inline(items.get('designed', ''))
351+
developed = parse_inline(items.get('developed', ''))
350352
license_text = parse_inline(items.get('license', ''))
351-
handle = parse_inline(items.get('handle', ''))
352-
year = items.get('year', '')
353-
354-
lately = render_lately(lately_md)
355353

356354
return (
357-
' <div class="footer-email">\n'
358-
f' <span>{email}</span>\n'
359-
' <button class="footer-email-copy" onclick="copyEmail()" aria-label="Copy email">\n'
360-
' <span class="material-symbols-sharp">content_copy</span>\n'
361-
' </button>\n'
362-
' </div>\n'
363-
'\n'
364-
f'{lately}\n'
365-
'\n'
366355
' <div class="footer-meta">\n'
367356
f' <span>{location}</span>\n'
368-
'\n'
369357
' <span id="local-time">--:--</span>\n'
370-
'\n'
371358
' <span id="temperature">--</span>\n'
372-
'\n'
373359
' <span>AQI <span id="aqi">--</span></span>\n'
374360
' </div>\n'
375361
'\n'
376362
' <div class="footer-bottom">\n'
377-
f' <span>{license_text} \u00b7 {handle} \u00b7 {year}</span>\n'
378-
f' <span>{credit}</span>\n'
363+
f' <span>{designed}</span>\n'
364+
f' <span>{developed}</span>\n'
365+
f' <span>{license_text}</span>\n'
379366
' </div>'
380367
)
381368

382369

370+
def render_work_section(md):
371+
"""Parse work.md into skills grid + services grid HTML."""
372+
lines = md.strip().split('\n')
373+
current_section = None
374+
skills = []
375+
services = []
376+
377+
for line in lines:
378+
stripped = line.strip()
379+
if stripped.startswith('# '):
380+
current_section = stripped[2:].strip().lower()
381+
elif stripped.startswith('- ') and current_section:
382+
value = stripped[2:].strip()
383+
if current_section == 'skills':
384+
skills.append(value)
385+
elif current_section == 'services':
386+
services.append(value)
387+
388+
skill_boxes = '\n'.join(
389+
f' <div class="skill-box">{s}</div>'
390+
for s in skills
391+
)
392+
service_boxes = '\n'.join(
393+
f' <div class="service-box">{s}</div>'
394+
for s in services
395+
)
396+
397+
return (
398+
' <!-- SKILLS -->\n'
399+
' <section>\n'
400+
' <h2 class="section-title"><span class="highlight">skills</span></h2>\n'
401+
' <div class="skills-grid">\n'
402+
f'{skill_boxes}\n'
403+
' </div>\n'
404+
' </section>\n'
405+
'\n'
406+
' <!-- SERVICES -->\n'
407+
' <section>\n'
408+
' <h2 class="section-title"><span class="highlight">services</span></h2>\n'
409+
' <div class="services-grid">\n'
410+
f'{service_boxes}\n'
411+
' </div>\n'
412+
' </section>'
413+
)
414+
415+
416+
def fetch_glass_photos():
417+
"""Fetch photos from Glass.photo profile, return list of {url, description}."""
418+
try:
419+
url = 'https://glass.photo/thedataareclean'
420+
req = urllib.request.Request(url, headers={
421+
'User-Agent': 'Mozilla/5.0 (compatible; portfolio-build/1.0)',
422+
})
423+
with urllib.request.urlopen(req, timeout=15) as resp:
424+
html = resp.read().decode('utf-8')
425+
426+
# Extract __NEXT_DATA__ JSON
427+
match = re.search(
428+
r'<script\s+id="__NEXT_DATA__"\s+type="application/json">\s*({.+?})\s*</script>',
429+
html,
430+
re.DOTALL,
431+
)
432+
if not match:
433+
print('Warning: Could not find __NEXT_DATA__ in Glass.photo page.')
434+
return []
435+
436+
data = json.loads(match.group(1))
437+
438+
# Navigate to posts array: props.pageProps.fallbackData.posts.data
439+
posts = (
440+
data.get('props', {})
441+
.get('pageProps', {})
442+
.get('fallbackData', {})
443+
.get('posts', {})
444+
.get('data', [])
445+
)
446+
447+
photos = []
448+
for post in posts:
449+
img_url = post.get('image1024x1024', '')
450+
desc = post.get('description', '')
451+
if img_url:
452+
photos.append({
453+
'url': img_url,
454+
'description': desc or '',
455+
})
456+
457+
print(f'Fetched {len(photos)} photos from Glass.photo')
458+
return photos
459+
460+
except Exception as e:
461+
print(f'Warning: Glass.photo fetch failed ({e}), using empty gallery.')
462+
return []
463+
464+
465+
def render_gallery(photos, num_cols=3):
466+
"""Render masonry gallery with round-robin column distribution for L-to-R chronological order."""
467+
if not photos:
468+
return ''
469+
470+
# Distribute photos round-robin across columns
471+
cols = [[] for _ in range(num_cols)]
472+
for i, photo in enumerate(photos):
473+
cols[i % num_cols].append(photo)
474+
475+
col_parts = []
476+
for col in cols:
477+
items = []
478+
for photo in col:
479+
desc = escape(photo['description'])
480+
caption = f'\n <figcaption>{desc}</figcaption>' if desc else ''
481+
items.append(
482+
f' <figure class="gallery-item">\n'
483+
f' <img src="{photo["url"]}" alt="{desc}" loading="lazy">{caption}\n'
484+
f' </figure>'
485+
)
486+
col_parts.append(
487+
' <div class="gallery-col">\n'
488+
+ '\n'.join(items) + '\n'
489+
+ ' </div>'
490+
)
491+
492+
return (
493+
' <!-- GALLERY -->\n'
494+
' <section>\n'
495+
' <h2 class="section-title"><span class="highlight">photos</span></h2>\n'
496+
' <div class="gallery">\n'
497+
+ '\n'.join(col_parts) + '\n'
498+
+ ' </div>\n'
499+
' </section>'
500+
)
501+
502+
503+
def render_home_body(hero_md):
504+
"""Home page body: hero."""
505+
hero_html = render_hero(hero_md)
506+
return hero_html
507+
508+
509+
def render_page_intro(text):
510+
"""Render an opening tagline, **bold** becomes accent-colored."""
511+
styled = re.sub(r'\*\*(.+?)\*\*', r'<span class="name">\1</span>', text)
512+
return (
513+
' <section class="page-intro">\n'
514+
f' <p class="tagline">{styled}</p>\n'
515+
' </section>'
516+
)
517+
518+
519+
def render_work_body(work_md, projects_md, about_md):
520+
"""Work page body: intro + about + skills + services + projects."""
521+
intro_html = render_page_intro('at work, i am a multi-disciplinary **data communicator**.')
522+
about_html = render_about(about_md)
523+
work_html = render_work_section(work_md)
524+
projects_html = render_projects(projects_md)
525+
return intro_html + '\n\n' + about_html + '\n\n' + work_html + '\n\n' + projects_html
526+
527+
528+
def render_play_body(lately_md, photos):
529+
"""Play page body: intro + lately + photo gallery."""
530+
intro_html = render_page_intro('during play, i do whatever i want.')
531+
lately_html = (
532+
' <!-- LATELY -->\n'
533+
' <section class="lately-section">\n'
534+
' <h2 class="section-title"><span class="highlight">lately</span></h2>\n'
535+
+ render_lately(lately_md) + '\n'
536+
+ ' </section>'
537+
)
538+
gallery_html = render_gallery(photos)
539+
parts = [intro_html, lately_html]
540+
if gallery_html:
541+
parts.append(gallery_html)
542+
return '\n\n'.join(parts)
543+
544+
545+
def render_page(template, css, js, meta_html, footer_html, body_html, active_nav, depth=0):
546+
"""Assemble a full HTML page with shared nav/footer, inlined CSS/JS."""
547+
# Navigation root path (relative)
548+
nav_root = '../' * depth if depth > 0 else ''
549+
# Asset path prefix
550+
asset_prefix = '../' * depth if depth > 0 else ''
551+
552+
html = template
553+
html = html.replace('{{meta}}', meta_html)
554+
html = html.replace('{{body}}', body_html)
555+
html = html.replace('{{footer}}', footer_html)
556+
557+
# Nav active states
558+
html = html.replace('{{nav_root}}', nav_root)
559+
html = html.replace('{{nav_work_active}}', 'active' if active_nav == 'work' else '')
560+
html = html.replace('{{nav_play_active}}', 'active' if active_nav == 'play' else '')
561+
562+
# Fix asset paths for subpages
563+
if asset_prefix:
564+
html = html.replace('href="assets/', f'href="{asset_prefix}assets/')
565+
html = html.replace('src="assets/', f'src="{asset_prefix}assets/')
566+
html = html.replace('content="assets/', f'content="{asset_prefix}assets/')
567+
568+
# Inline minified CSS
569+
html = html.replace(
570+
' <link rel="stylesheet" href="style.css">',
571+
' <style>\n' + minify_css(css) + ' </style>',
572+
)
573+
574+
# Inline minified JS
575+
html = html.replace(
576+
' <script src="script.js"></script>',
577+
' <script>\n' + minify_js(js) + ' </script>',
578+
)
579+
580+
return html
581+
582+
383583
def minify_css(css):
384584
"""Remove comments and collapse whitespace in CSS."""
385585
css = re.sub(r'/\*.*?\*/', '', css, flags=re.DOTALL)
@@ -413,33 +613,19 @@ def build():
413613
projects_md = read(CONTENT / 'projects.md')
414614
lately_md = read(CONTENT / 'lately.md')
415615
footer_md = read(CONTENT / 'footer.md')
616+
work_md = read(CONTENT / 'work.md')
416617

417-
# Render content sections
618+
# Shared pieces
418619
meta_html = render_meta(meta_md)
419-
hero_html = render_hero(hero_md)
420-
about_html = render_about(about_md)
421-
projects_html = render_projects(projects_md)
422-
footer_html = render_footer(footer_md, lately_md)
620+
footer_html = render_footer(footer_md)
423621

424-
# Assemble page
425-
html = template
426-
html = html.replace('{{meta}}', meta_html)
427-
html = html.replace('{{hero}}', hero_html)
428-
html = html.replace('{{about}}', about_html)
429-
html = html.replace('{{projects}}', projects_html)
430-
html = html.replace('{{footer}}', footer_html)
622+
# Fetch Glass.photo images
623+
photos = fetch_glass_photos()
431624

432-
# Inline minified CSS
433-
html = html.replace(
434-
' <link rel="stylesheet" href="style.css">',
435-
' <style>\n' + minify_css(css) + ' </style>',
436-
)
437-
438-
# Inline minified JS
439-
html = html.replace(
440-
' <script src="script.js"></script>',
441-
' <script>\n' + minify_js(js) + ' </script>',
442-
)
625+
# Page bodies
626+
home_body = render_home_body(hero_md)
627+
work_body = render_work_body(work_md, projects_md, about_md)
628+
play_body = render_play_body(lately_md, photos)
443629

444630
# Generate seasonal images
445631
month = datetime.now().month - 1 # 0-indexed
@@ -450,9 +636,21 @@ def build():
450636
except Exception as e:
451637
print(f'Warning: image generation failed ({e}), skipping.')
452638

453-
# Write output
639+
# Build pages
640+
pages = [
641+
('index.html', home_body, 'home', 0),
642+
('work/index.html', work_body, 'work', 1),
643+
('play/index.html', play_body, 'play', 1),
644+
]
645+
454646
DIST.mkdir(exist_ok=True)
455-
(DIST / 'index.html').write_text(html)
647+
648+
for path, body, active, depth in pages:
649+
html = render_page(template, css, js, meta_html, footer_html, body, active, depth)
650+
out = DIST / path
651+
out.parent.mkdir(parents=True, exist_ok=True)
652+
out.write_text(html)
653+
print(f'Built dist/{path} ({len(html)} bytes)')
456654

457655
# Copy assets
458656
assets_src = BASE / 'assets'
@@ -462,7 +660,7 @@ def build():
462660
shutil.rmtree(assets_dst)
463661
shutil.copytree(assets_src, assets_dst, ignore=shutil.ignore_patterns('README.md'))
464662

465-
print(f'Built dist/index.html ({len(html)} bytes) — og accent: {accent}')
663+
print(f'Done — og accent: {accent}')
466664

467665

468666
if __name__ == '__main__':

0 commit comments

Comments
 (0)