2025-05-01 02:56:20 +02:00
#!/usr/bin/env python3
import html
import os
import sys
import shutil
html_self_closing_tags = { " !DOCTYPE " , " area " , " base " , " br " , " col " , " embed " , " hr " , " img " , " input " , " link " , " meta " , " param " , " source " , " track " , " wbr " , " command " , " keygen " , " menuitem " , " frame " }
html_xml_content_tags = { " svg " }
text_url_match = { " / " , " http:// " , " https:// " }
traversed_links = set ( )
config_values = {
" logo " : " " ,
" logo_url " : " / " ,
" blank " : False ,
" icon " : " /favicon.ico " ,
" stylesheet " : " /stylesheet.css " ,
" title_format " : " % t " ,
" logo_width " : " 192 " ,
" logo_height " : " 32 "
}
default_theme = {
" background-color " : " #f0f0f0 " ,
" text-color " : " #7f687f " ,
" link-color " : " #af8faf " ,
" header-color " : " #e0e0e0 " ,
" footer-color " : " #e0e0e0 " ,
" top-line-color " : " #c0c0c0 " ,
" bottom-line-color " : " #c0c0c0 " ,
" container-color " : " #f0f0f0 " ,
" content-box-color " : " #e8e8e8 " ,
" content-border-tl-color " : " #f8f8f8 " ,
" content-border-br-color " : " #c8c8c8 " ,
" container-padding " : 80 ,
" content-min-height " : 36 ,
" content-margin " : 5 ,
" content-line-pad " : 5 ,
" content-side-pad " : 10 ,
" header-height " : 40 ,
" header-padding-left " : 10 ,
" header-padding-right " : 10 ,
" header-gap " : 5 ,
" header-elem-size " : 32 ,
" header-line-pad " : 4 ,
" header-side-pad " : 10 ,
" footer-height " : 32 ,
" footer-padding-left " : 15 ,
" footer-padding-right " : 15 ,
" footer-gap " : 8 ,
" footer-elem-size " : 20 ,
" footer-line-pad " : 2 ,
" footer-side-pad " : 7 ,
" line-height " : 20 ,
" text-size " : 14 ,
" header-text-size " : 14 ,
" footer-text-size " : 14
}
base_stylesheet = [
( " a " , {
" color " : " [#link-color] " ,
" text-decoration " : " none "
} ) ,
( " body " , {
" box-sizing " : " border-box " ,
" line-height " : " [#line-height]px " ,
" display " : " flex " ,
" flex-direction " : " column " ,
" background-color " : " [#background-color] " ,
" margin " : " 0 " ,
" color " : " [#text-color] " ,
" font-size " : " [#text-size]px "
} ) ,
( " .flex-container " , {
" box-sizing " : " border-box " ,
" display " : " flex " ,
" justify-content " : " space-between " ,
" margin " : " 0 "
} ) ,
( " nav.flex-container " , {
" background-color " : " [#header-color] " ,
" border-style " : " solid " ,
" border-width " : " 0 0 1px 0 " ,
" border-color " : " [#top-line-color] "
} ) ,
( " footer.flex-container " , {
" background-color " : " [#footer-color] " ,
" border-style " : " solid " ,
" border-width " : " 1px 0 0 0 " ,
" border-color " : " [#bottom-line-color] "
} ) ,
( " .full-container " , {
" box-sizing " : " border-box " ,
" padding " : " 0 0 [#container-padding]px 0 " ,
" flex-grow " : " 1 " ,
" background-color " : " [#container-color] " ,
" margin " : " 0 "
} ) ,
( " .base-container " , {
" min-height " : " [#content-min-height]px " ,
" align-items " : " center " ,
" box-sizing " : " border-box " ,
" padding " : " [#content-line-pad]px [#content-side-pad]px " ,
" background-color " : " [#content-box-color] " ,
" margin " : " [#content-margin]px " ,
" border-style " : " solid " ,
" border-width " : " 1px " ,
" border-color " : " [#content-border-tl-color] [#content-border-br-color] [#content-border-br-color] [#content-border-tl-color] "
} ) ,
( " .list-container " , {
" column-gap " : " [#header-gap]px " ,
" display " : " flex " ,
" min-height " : " [#header-height]px " ,
" row-gap " : " [#header-gap]px " ,
" align-items " : " center " ,
" box-sizing " : " border-box " ,
2025-06-09 14:07:23 +02:00
" flex-wrap " : " wrap " ,
2025-05-01 02:56:20 +02:00
" padding " : " 0 [#header-padding-right]px 0 [#header-padding-left]px " ,
" margin " : " 0 "
} ) ,
( " .compact-container " , {
" column-gap " : " [#footer-gap]px " ,
" display " : " flex " ,
" min-height " : " [#footer-height]px " ,
" row-gap " : " [#footer-gap]px " ,
" align-items " : " center " ,
" box-sizing " : " border-box " ,
2025-06-09 14:07:23 +02:00
" flex-wrap " : " wrap " ,
2025-05-01 02:56:20 +02:00
" padding " : " 0 [#footer-padding-right]px 0 [#footer-padding-left]px " ,
" margin " : " 0 "
} ) ,
( " .list-item " , {
" align-items " : " center " ,
" align-self " : " center " ,
" box-sizing " : " border-box " ,
" display " : " flex " ,
" flex-basis " : " auto " ,
" flex-grow " : " 0 " ,
" flex-shrink " : " 0 " ,
" min-height " : " [#header-elem-size]px " ,
" min-width " : " [#header-elem-size]px " ,
" padding " : " [#header-line-pad]px [#header-side-pad]px " ,
" margin " : " 0 " ,
" flex-wrap " : " wrap " ,
" font-size " : " [#header-text-size]px "
} ) ,
( " .compact-item " , {
" align-items " : " center " ,
" align-self " : " center " ,
" box-sizing " : " border-box " ,
" display " : " flex " ,
" flex-basis " : " auto " ,
" flex-grow " : " 0 " ,
" flex-shrink " : " 0 " ,
" min-height " : " [#footer-elem-size]px " ,
" min-width " : " [#footer-elem-size]px " ,
" padding " : " [#footer-line-pad]px [#footer-side-pad]px " ,
" margin " : " 0 " ,
" flex-wrap " : " wrap " ,
" font-size " : " [#footer-text-size]px "
} ) ,
( " a.nocolor " , {
" color " : " inherit "
} )
]
def tree2html ( tree , indent = 0 ) :
lines = [ ]
for node in tree :
if type ( node ) is not tuple or len ( node ) < 1 or len ( node ) > 3 or type ( node [ 0 ] ) is not str or ( len ( node ) == 2 and type ( node [ 1 ] ) not in { dict , list } ) or ( len ( node ) == 3 and ( type ( node [ 1 ] ) is not dict or type ( node [ 2 ] ) is not list ) ) :
raise Exception ( " element ist not a valid html tuple: (tag), (tag, properties), (tag, children) or (tag, properties, children), got " + str ( type ( node ) ) + " ( " + str ( node ) + " ) " )
props = " "
inner = " "
if len ( node ) > 1 and type ( node [ 1 ] ) is dict :
for prop , value in node [ 1 ] . items ( ) :
props + = ' ' + html . escape ( prop ) + ( ( ' = " ' + html . escape ( value ) + ' " ' ) if value is not None else " " )
if len ( node ) == 3 or ( len ( node ) == 2 and not props ) :
if node [ 0 ] in html_self_closing_tags :
raise Exception ( " self closing tag < " + node [ 0 ] + " > cannot have children " )
lines . append ( ( indent * " \t " ) + " < " + node [ 0 ] + props + " > " )
for child in node [ 2 if len ( node ) == 3 else 1 ] :
if type ( child ) is list :
lines . extend ( tree2html ( child , indent + 1 ) )
elif type ( child ) is tuple :
lines . extend ( tree2html ( [ child ] , indent + 1 ) )
elif type ( child ) is str :
lines . append ( ( ( indent + 1 ) * " \t " ) + ( child if node [ 0 ] in html_xml_content_tags else html . escape ( child ) ) )
else :
raise Exception ( " element must be tuple, list or str, got " + str ( type ( child ) ) + " ( " + str ( child ) + " ) " )
lines . append ( ( indent * " \t " ) + " </ " + node [ 0 ] + " > " )
else :
lines . append ( ( indent * " \t " ) + " < " + node [ 0 ] + props + " > " + ( ( " </ " + node [ 0 ] + " > " ) if node [ 0 ] not in html_self_closing_tags else " " ) )
return lines
def get_key_value ( text , separator = " " ) :
space = text . find ( separator )
if space < 0 :
return ( text , None )
else :
return ( text [ : space ] , text [ space + 1 : ] )
def get_html_tags ( line , variables ) :
if not line :
return None
elif line . startswith ( " ### " ) :
return [ ( " h1 " , text2tree ( line [ 3 : ] ) ) ]
elif line . startswith ( " ## " ) :
return [ ( " h2 " , text2tree ( line [ 2 : ] ) ) ]
elif line . startswith ( " # " ) :
return [ ( " h3 " , text2tree ( line [ 1 : ] ) ) ]
elif line . startswith ( " [# " ) and line . endswith ( " ] " ) :
key , value = get_key_value ( line [ 2 : - 1 ] )
match key :
case " svg " :
svg = value . split ( " " , 6 )
return [ ( " svg " , { " width " : svg [ 0 ] , " height " : svg [ 1 ] , " viewBox " : svg [ 2 ] + " " + svg [ 3 ] + " " + svg [ 4 ] + " " + svg [ 5 ] } , [ svg [ 6 ] ] ) ]
case " img " :
img = value . split ( " " , 2 )
src , alt = get_key_value ( img [ 0 ] , " : " )
props = { " src " : src }
if src . startswith ( " / " ) :
global traversed_links
traversed_links . add ( src )
if alt is not None :
props [ " alt " ] = alt
if len ( img ) > = 2 and ( img [ 1 ] . startswith ( " w: " ) or img [ 1 ] . startswith ( " h: " ) ) :
props [ " width " if img [ 1 ] . startswith ( " w: " ) else " height " ] = img [ 1 ] [ 2 : ]
elif len ( img ) > = 3 :
props [ " width " ] = img [ 1 ]
props [ " height " ] = img [ 2 ]
return [ ( " a " , { " href " : src } , [ ( " img " , props ) ] ) ]
if key in variables . keys ( ) :
return variables [ key ]
return None
def text2tree ( line , nocolor = False ) :
blank = config_values [ " blank " ]
tree = [ ]
tpos = pos = 0
while True :
lpos = line . find ( " [ " , pos )
if lpos < 0 :
break
epos = line . find ( " ] " , lpos + 1 )
if epos < 0 :
break
sub = line [ lpos + 1 : epos ]
for prefix in text_url_match :
if sub . startswith ( prefix ) :
url , alt = get_key_value ( sub )
props = { " href " : url }
if nocolor :
props [ " class " ] = " nocolor "
if prefix == " / " :
global traversed_links
traversed_links . add ( url )
if blank :
props [ " target " ] = " _blank "
if tpos < lpos :
tree . append ( line [ tpos : lpos ] )
tree . append ( ( " a " , props , [ alt if alt else url ] ) )
tpos = epos + 1 ;
break
pos = epos + 1 ;
if tpos < len ( line ) :
tree . append ( line [ tpos : ] )
return tree
def lines2tree ( text , variables ) :
tree = [ ]
paragraph = [ ]
for line in text :
tags = get_html_tags ( line , variables )
if tags :
if paragraph :
tree . append ( ( " p " , paragraph ) )
paragraph = [ ]
tree . extend ( tags )
continue
if paragraph :
paragraph . append ( ( " br " , ) )
paragraph . extend ( text2tree ( line ) )
if paragraph :
tree . append ( ( " p " , paragraph ) )
return tree
def line2tree ( line , variables , nocolor ) :
tags = get_html_tags ( line , variables )
if tags :
return tags
return text2tree ( line , nocolor )
def filter_text ( data ) :
for elem in data :
if type ( elem ) is tuple and elem [ 0 ] in { " svg " , " img " } :
return [ ( " span " , [ elem ] ) if type ( elem ) is str else elem for elem in data ]
return [ ( " span " , data ) ]
def filter_entry ( entry , clazz ) :
elem = entry if type ( entry ) is not list or len ( entry ) == 0 else entry [ 0 ]
if type ( elem ) == tuple and len ( elem ) > = 2 and type ( elem [ 1 ] ) == dict and " class " in elem [ 1 ] . keys ( ) and clazz + " -item " in elem [ 1 ] [ " class " ] . split ( " " ) :
return entry if type ( entry ) == list else [ entry ]
else :
return [ ( " p " , { " class " : clazz + " -item " } , filter_text ( entry ) ) ]
def make_element ( elems , variables , clazz ) :
tree = [ ]
arr = None
for elem in elems :
if type ( elem ) is list :
data = elem
elif type ( elem ) is tuple :
data = [ elem ]
elif type ( elem ) is str :
if elem == " [ " :
if arr is not None :
data = arr
arr = [ ]
else :
arr = [ ]
continue
elif elem == " ] " :
if arr is None :
continue
data = arr
arr = None
else :
data = line2tree ( elem , variables , clazz == " list " )
if arr is not None :
arr . extend ( data )
continue
else :
data = str ( elem )
tree . extend ( filter_entry ( data , clazz ) )
if arr is not None :
tree . extend ( filter_entry ( arr , clazz ) )
return ( " div " , { " class " : clazz + " -container " } , tree )
def make_head ( title ) :
return [
( " meta " , { " http-equiv " : " content-type " , " content " : " text/html; charset=UTF-8 " } ) ,
( " title " , [ config_values [ " title_format " ] . replace ( " % t " , title ) ] ) ,
( " link " , { " rel " : " icon " , " type " : " image/x-icon " , " href " : config_values [ " icon " ] } ) ,
( " link " , { " rel " : " stylesheet " , " href " : config_values [ " stylesheet " ] } )
]
def make_links ( pages , clazz ) :
logo = config_values [ " logo " ]
logo_url = config_values [ " logo_url " ]
logo_width = config_values [ " logo_width " ]
logo_height = config_values [ " logo_height " ]
nav = [ ]
if logo :
alt = " Logo "
for page in pages :
if page [ " url " ] is logo_url :
if page [ " name " ] :
alt = page [ " name " ]
break
nav . append ( ( " a " , { " href " : logo_url , " class " : clazz + " -item nocolor " } , [ ( " img " , { " width " : logo_width , " height " : logo_height , " src " : logo , " alt " : " Logo " } ) ] ) )
nav . extend ( [ ( " a " , { " href " : page [ " url " ] , " class " : clazz + " -item nocolor " } , [ page [ " name " ] ] ) for page in pages if page [ " name " ] and ( not logo or page [ " url " ] is not logo_url ) ] )
return nav
def make_bar ( left , center , right , pages , variables , clazz , tag ) :
variables = variables . copy ( )
variables [ " nav " ] = make_links ( pages , clazz )
bar = [ make_element ( left , variables , clazz ) , make_element ( right , variables , clazz ) ]
if center :
bar = [ bar [ 0 ] , make_element ( center , variables , clazz ) , bar [ 1 ] ]
return ( tag , { " class " : " flex-container " } , bar )
def make_content ( sections , variables ) :
tree = [ ]
for section in sections :
tree . append ( ( " div " , { " class " : " base-container " } , lines2tree ( section , variables ) ) )
return ( " div " , { " class " : " full-container " } , tree )
def compile_page ( page , header , footer , variables ) :
return [ ( " !DOCTYPE " , { " html " : None } ) , ( " html " , [ ( " head " , make_head ( page [ " title " ] ) ) , ( " body " , [ header , make_content ( page [ " sections " ] , variables ) , footer ] ) ] ) ]
def sort_pages ( page ) :
return page [ " sort " ]
def compile_pages ( pages ) :
pages . sort ( key = sort_pages )
variables = {
}
header = make_bar ( read_list ( " header/left " , [ " [#nav] " ] ) , read_list ( " header/center " ) , read_list ( " header/right " ) , pages , variables , " list " , " nav " )
footer = make_bar ( read_list ( " footer/left " ) , read_list ( " footer/center " ) , read_list ( " footer/right " ) , pages , variables , " compact " , " footer " )
return [ ( page [ " file " ] , compile_page ( page , header , footer , variables ) ) for page in pages ]
def read_page ( filename ) :
path = os . path . join ( config_values [ " indir " ] , filename + " .sml " )
print ( " Reading " + path )
with open ( path , " r " ) as fd :
data = fd . read ( ) . splitlines ( )
page = {
" name " : None ,
" title " : filename ,
" sort " : 0
}
sections = [ ]
subpage = [ ]
for line in data :
if line . startswith ( " [# " ) and line . endswith ( " ] " ) :
key , value = get_key_value ( line [ 2 : - 1 ] )
match key :
case " name " :
page [ " name " ] = value
case " title " :
page [ " title " ] = value
case " sort " :
page [ " sort " ] = int ( value )
case " index " :
if os . path . basename ( filename ) != " index " :
filename = os . path . join ( filename , " index " )
case " " :
sections . append ( subpage )
subpage = [ ]
case _ :
subpage . append ( line )
else :
subpage . append ( line )
if subpage :
sections . append ( subpage )
page [ " sections " ] = sections
page [ " url " ] = get_file_url ( filename )
page [ " file " ] = filename
return page
def read_list ( filename , default = [ ] , directory = None ) :
path = os . path . join ( config_values [ " indir " ] if directory is None else directory , filename + " .sml " )
if not os . path . exists ( path ) :
if default is not None :
print ( " Writing default " + path )
base = os . path . dirname ( path )
if not os . path . exists ( base ) :
os . makedirs ( base )
with open ( path , " w " ) as fd :
fd . write ( ' \n ' . join ( default ) )
return default
return [ ]
print ( " Reading " + path )
with open ( path , " r " ) as fd :
data = fd . read ( ) . splitlines ( )
return data
def write_page ( filename , data , extension = " html " ) :
filename + = ( ( " . " + extension ) if extension else " " )
path = os . path . join ( config_values [ " outdir " ] , filename )
print ( " Writing " + path )
base = os . path . dirname ( path )
if not os . path . exists ( base ) :
os . makedirs ( base )
with open ( path , " w " ) as fd :
fd . write ( ' \n ' . join ( data ) )
return filename
def read_pages ( ) :
directory = config_values [ " indir " ]
pages = [ ]
for root , dirs , files in os . walk ( directory ) :
for filename in files :
if not filename . endswith ( " .sml " ) :
continue
path = os . path . join ( os . path . relpath ( root , directory ) , filename )
path = path [ 2 : - 4 ] if path . split ( os . sep ) [ 0 ] == " . " else path [ : - 4 ]
if os . path . dirname ( path ) not in { " header " , " footer " } and path != " config " and path != " theme " :
pages . append ( read_page ( path ) )
return pages
def copy_file ( filename ) :
ipath = os . path . join ( config_values [ " indir " ] , filename )
opath = os . path . join ( config_values [ " outdir " ] , filename )
if not os . path . exists ( ipath ) :
print ( " Warning: file " + ipath + " does not exist " )
return
print ( " Copying " + ipath + " to " + opath )
base = os . path . dirname ( opath )
if not os . path . exists ( base ) :
os . makedirs ( base )
shutil . copy ( ipath , opath )
def read_config ( directory , output ) :
global config_values
config = read_list ( " config " , [ " [# " + ( prop if type ( value ) is bool else prop + " " + str ( value ) ) + " ] " for prop , value in config_values . items ( ) ] , directory )
for cfg in config :
if cfg . startswith ( " [# " ) and cfg . endswith ( " ] " ) :
prop , value = get_key_value ( cfg [ 2 : - 1 ] )
if prop not in config_values . keys ( ) :
print ( " Warning: config value ' " + prop + " ' is unknown " )
continue
ovalue = config_values [ prop ]
if value is None :
if type ( ovalue ) is not bool :
print ( " Warning: config value ' " + prop + " ' needs an argument " )
continue
value = True
elif type ( ovalue ) is bool :
if value . lower ( ) != " true " and value . lower ( ) != " false " :
print ( " Warning: unknown bool value for config value ' " + prop + " ' " )
continue
value = value . lower ( ) == " true "
print ( " Warning: redundant bool value for config value ' " + prop + " ' " )
config_values [ prop ] = value
config_values [ " indir " ] = directory
config_values [ " outdir " ] = output
for prop , value in config_values . items ( ) :
print ( prop + " = " + str ( value ) )
def add_default_links ( ) :
global traversed_links
for key in " logo " , " icon " , " stylesheet " :
if config_values [ key ] . startswith ( " / " ) :
traversed_links . add ( config_values [ key ] )
def read_theme ( ) :
theme = read_list ( " theme " , [ " [# " + prop + " " + str ( value ) + " ] " for prop , value in default_theme . items ( ) ] )
style = { prop : str ( value ) for prop , value in default_theme . items ( ) }
for line in theme :
if line . startswith ( " [# " ) and line . endswith ( " ] " ) :
prop , value = get_key_value ( line [ 2 : - 1 ] )
if prop not in default_theme . keys ( ) :
print ( " Warning: theme property ' " + prop + " ' is unknown " )
continue
if value is None :
continue
if type ( default_theme [ prop ] ) is int :
test = int ( value )
style [ prop ] = value
return style
def compile_stylesheet ( classes , variables ) :
lines = [ ]
for clazz , props in classes :
lines . append ( clazz + " { " )
for prop , value in props . items ( ) :
plain = " "
tpos = pos = 0
while True :
lpos = value . find ( " [# " , pos )
if lpos < 0 :
break
epos = value . find ( " ] " , lpos + 2 )
if epos < 0 :
break
key , _ = get_key_value ( value [ lpos + 2 : epos ] )
if key in variables . keys ( ) :
if tpos < lpos :
plain + = value [ tpos : lpos ]
plain + = variables [ key ]
tpos = epos + 1 ;
pos = epos + 1 ;
if tpos < len ( value ) :
plain + = value [ tpos : ]
lines . append ( " \t " + prop + " : " + plain + " ; " )
lines . append ( " } " )
return lines
def write_stylesheet ( style ) :
return write_page ( config_values [ " stylesheet " ] [ 1 : ] , style , extension = None )
def write_pages ( pages ) :
written = set ( )
for path , tree in pages :
written . add ( write_page ( path , tree2html ( tree ) ) )
return written
def get_url_file ( url ) :
url = url [ 1 : ]
return url if " . " in url else ( " index.html " if not url else os . path . join ( url [ : - 1 ] if url . endswith ( " / " ) else url , " index.html " ) )
def get_file_url ( filename ) :
return " / " + ( ( " " if filename == " index " else os . path . dirname ( filename ) ) if os . path . basename ( filename ) == " index " else ( filename + " .html " ) )
def copy_linked_files ( written ) :
for url in traversed_links :
filename = get_url_file ( url )
if filename not in written :
copy_file ( filename )
def clean_directory ( ) :
output = config_values [ " outdir " ]
if os . path . exists ( output ) :
shutil . rmtree ( output )
def main ( ) :
if len ( sys . argv ) < 2 :
print ( sys . argv [ 0 ] + " <indir> <outdir> " )
return
read_config ( sys . argv [ 1 ] , sys . argv [ 2 ] )
add_default_links ( )
pages = read_pages ( )
theme = read_theme ( )
pages = compile_pages ( pages )
style = compile_stylesheet ( base_stylesheet , theme )
clean_directory ( )
written = write_pages ( pages )
written . add ( write_stylesheet ( style ) )
copy_linked_files ( written )
if __name__ == " __main__ " :
main ( )