33"""Build script for portfolio site.
44
55Reads markdown content files, renders them into HTML,
6- and assembles the final dist/index.html .
6+ and assembles multi-page output: /, /work/, /play/ .
77
88Usage: python3 build.py
99"""
1010
11+ import json
1112import re
1213import shutil
14+ import urllib .request
1315from datetime import datetime
16+ from html import escape
1417from pathlib import Path
1518
1619BASE = 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+
383583def 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
468666if __name__ == '__main__' :
0 commit comments