sitegen.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. import argparse
  2. from functools import lru_cache
  3. from importlib import import_module
  4. from jinja2 import Environment, FileSystemLoader, contextfilter
  5. from pathlib import Path
  6. from os import walk, makedirs, listdir, symlink, readlink
  7. from os.path import join, exists, splitext, split, islink, isdir
  8. from shutil import rmtree, copy2, copystat, ignore_patterns, Error
  9. from time import sleep
  10. from traceback import print_exc
  11. from urllib.parse import quote
  12. import os
  13. #TODO: load from config file (and watch it too)
  14. LANGUAGES = 'languages'
  15. PLUGINS = 'plugins'
  16. ROOT = 'root'
  17. STATIC = 'static'
  18. DEFAULT_LANG = 'en'
  19. OTHER_LANGS = set(['es'])
  20. WATCH_INTERVAL = 1 # in secs
  21. config = {
  22. 'LANGUAGES': LANGUAGES,
  23. 'PLUGINS': PLUGINS,
  24. 'ROOT': ROOT,
  25. 'STATIC': STATIC,
  26. 'DEFAULT_LANG': DEFAULT_LANG,
  27. 'OTHER_LANGS': OTHER_LANGS,
  28. 'WATCH_INTERVAL': WATCH_INTERVAL
  29. }
  30. # Utils
  31. ignore_underscores = ignore_patterns('_*')
  32. def copytree(src, dst, symlinks=False, ignore=None, copy_function=copy2,
  33. ignore_dangling_symlinks=False):
  34. """Recursively copy a directory tree.
  35. The destination directory must not already exist.
  36. If exception(s) occur, an Error is raised with a list of reasons.
  37. If the optional symlinks flag is true, symbolic links in the
  38. source tree result in symbolic links in the destination tree; if
  39. it is false, the contents of the files pointed to by symbolic
  40. links are copied. If the file pointed by the symlink doesn't
  41. exist, an exception will be added in the list of errors raised in
  42. an Error exception at the end of the copy process.
  43. You can set the optional ignore_dangling_symlinks flag to true if you
  44. want to silence this exception. Notice that this has no effect on
  45. platforms that don't support os.symlink.
  46. The optional ignore argument is a callable. If given, it
  47. is called with the `src` parameter, which is the directory
  48. being visited by copytree(), and `names` which is the list of
  49. `src` contents, as returned by os.listdir():
  50. callable(src, names) -> ignored_names
  51. Since copytree() is called recursively, the callable will be
  52. called once for each directory that is copied. It returns a
  53. list of names relative to the `src` directory that should
  54. not be copied.
  55. The optional copy_function argument is a callable that will be used
  56. to copy each file. It will be called with the source path and the
  57. destination path as arguments. By default, copy2() is used, but any
  58. function that supports the same signature (like copy()) can be used.
  59. """
  60. names = os.listdir(src)
  61. if ignore is not None:
  62. ignored_names = ignore(src, names)
  63. else:
  64. ignored_names = set()
  65. os.makedirs(dst, exist_ok=True)
  66. # `exist_ok=True`, very important for hot reloading
  67. errors = []
  68. for name in names:
  69. if name in ignored_names:
  70. continue
  71. srcname = os.path.join(src, name)
  72. dstname = os.path.join(dst, name)
  73. try:
  74. if os.path.islink(srcname):
  75. linkto = os.readlink(srcname)
  76. if symlinks:
  77. # We can't just leave it to `copy_function` because legacy
  78. # code with a custom `copy_function` may rely on copytree
  79. # doing the right thing.
  80. os.symlink(linkto, dstname)
  81. copystat(srcname, dstname, follow_symlinks=not symlinks)
  82. else:
  83. # ignore dangling symlink if the flag is on
  84. if not os.path.exists(linkto) and ignore_dangling_symlinks:
  85. continue
  86. # otherwise let the copy occurs. copy2 will raise an error
  87. if os.path.isdir(srcname):
  88. copytree(srcname, dstname, symlinks, ignore,
  89. copy_function)
  90. else:
  91. copy_function(srcname, dstname)
  92. elif os.path.isdir(srcname):
  93. copytree(srcname, dstname, symlinks, ignore, copy_function)
  94. else:
  95. # Will raise a SpecialFileError for unsupported file types
  96. copy_function(srcname, dstname)
  97. # catch the Error from the recursive copytree so that we can
  98. # continue with other files
  99. except Error as err:
  100. errors.extend(err.args[0])
  101. except OSError as why:
  102. errors.append((srcname, dstname, str(why)))
  103. try:
  104. copystat(src, dst)
  105. except OSError as why:
  106. # Copying file access times may fail on Windows
  107. if getattr(why, 'winerror', None) is None:
  108. errors.append((src, dst, str(why)))
  109. if errors:
  110. raise Error(errors)
  111. return dst
  112. def mtimes(target_dir):
  113. ''''get modification time of files in `target_dir`'''
  114. return { f: f.stat().st_mtime for f in Path(target_dir).rglob('*') }
  115. def save_dic(lang, dic):
  116. with open(join(LANGUAGES, lang + '.txt'), 'w') as f:
  117. for key, value in dic.items():
  118. f.write(f"{key}:{value}\n")
  119. @lru_cache(None)
  120. def get_lang_dic(lang):
  121. makedirs(LANGUAGES, exist_ok=True)
  122. lang_file = join(LANGUAGES, lang + '.txt')
  123. if not exists(lang_file):
  124. return {}
  125. else:
  126. dic = {}
  127. with open(lang_file) as f:
  128. for line in f.readlines():
  129. key, value = line.strip('\n').split(':')
  130. dic[key] = value
  131. return dic
  132. # Filters
  133. def add_lang_prefix(lang, path):
  134. if lang == DEFAULT_LANG:
  135. return path
  136. if lang not in OTHER_LANGS:
  137. print(f"Not registered language: `{lang}`")
  138. return f"/{lang}{path}"
  139. @contextfilter
  140. def lang(ctx, value):
  141. lang = ctx.environment.globals['lang']
  142. if lang == DEFAULT_LANG:
  143. return value
  144. else:
  145. dic = get_lang_dic(lang)
  146. if value in dic and dic[value]:
  147. return dic[value]
  148. else:
  149. dic[value] = ""
  150. save_dic(lang, dic)
  151. print(f"Not translated phrase: `{value}`")
  152. return value
  153. @contextfilter
  154. def lang_url(ctx, lang):
  155. name, _ = splitext(ctx.name)
  156. if name.startswith('./'):
  157. name = name[2:]
  158. if name == 'index':
  159. name = ''
  160. return add_lang_prefix(lang, f"/{name}")
  161. @contextfilter
  162. def cur_lang(ctx, path):
  163. lang = ctx.environment.globals['lang']
  164. return add_lang_prefix(lang, path)
  165. def static(value):
  166. parts = value.parts if isinstance(value, Path) else value.split('/')
  167. sub_path = '/'.join(map(lambda s: quote(s), parts))
  168. return f"/static/{sub_path}"
  169. def svg(resource):
  170. with Path(STATIC, 'svg', f'{resource}.svg').open() as f:
  171. f.readline()
  172. f.readline()
  173. return f.read()
  174. def inline(file_name):
  175. return Path(file_name).read_text()
  176. # Compiler
  177. def compile(env, path, target):
  178. lang = env.globals['lang']
  179. for base, _, docs in walk(path):
  180. cur_base = Path(base)
  181. cur_base = Path(*cur_base.parts[1:])
  182. for doc in docs:
  183. if doc.startswith('_'):
  184. # Ignore this files
  185. continue
  186. src = join(base, doc)
  187. if args.with_index and doc != 'index.html':
  188. name, _ = splitext(doc)
  189. dst = join(cur_base, name, 'index.html')
  190. else:
  191. dst = join(cur_base, doc)
  192. if lang != DEFAULT_LANG:
  193. dst = join(lang, dst)
  194. dst = join(target, dst)
  195. dst_folder, _ = split(dst)
  196. makedirs(dst_folder, exist_ok=True)
  197. template = env.get_template(join(cur_base, doc))
  198. output = template.render()
  199. with open(dst, 'w') as f:
  200. f.write(output)
  201. def init_gen(args):
  202. # Load layout
  203. file_loader = FileSystemLoader(args.source)
  204. env = args.env = Environment(loader=file_loader)
  205. # Init gobals
  206. env.globals['lang'] = DEFAULT_LANG
  207. env.globals['list'] = list
  208. # Add filters
  209. env.filters['lang'] = lang
  210. env.filters['lang_url'] = lang_url
  211. env.filters['cur_lang'] = cur_lang
  212. env.filters['static'] = static
  213. env.filters['svg'] = svg
  214. env.filters['inline'] = inline
  215. # Load plugins
  216. for mod_path in Path(PLUGINS).glob('*.py'):
  217. mod_name = '.'.join(mod_path.with_suffix('').parts)
  218. print(f'* loading {mod_name}')
  219. import_module(mod_name).init_plugin(env, config)
  220. print(' done!')
  221. # Clean target
  222. if exists(args.target):
  223. rmtree(args.target)
  224. makedirs(args.target, exist_ok=True)
  225. def gen_layout(args):
  226. env = args.env
  227. path = Path(args.source)
  228. # Reset gobals
  229. env.globals['lang'] = DEFAULT_LANG
  230. # compile
  231. compile(env, path, args.target)
  232. for other_lang in OTHER_LANGS:
  233. env.globals['lang'] = other_lang
  234. compile(env, path, args.target)
  235. def gen_root(args):
  236. copytree(ROOT, args.target, ignore=ignore_underscores)
  237. def gen_static(args):
  238. copytree(STATIC, join(args.target, 'static'), ignore=ignore_underscores)
  239. def save_generate(generator, args, msg):
  240. print(f'* {msg}')
  241. try:
  242. generator(args)
  243. print(' done!')
  244. except:
  245. print_exc()
  246. return False
  247. return True
  248. # Runtime
  249. def run(args):
  250. print('initial compilation')
  251. init_gen(args)
  252. save_generate(gen_layout, args, 'generating layout')
  253. save_generate(gen_root, args, 'copying root files')
  254. save_generate(gen_static, args, 'copying static dir')
  255. if not args.watch:
  256. return
  257. print(f'watching {args.source}/*:{LANGUAGES}/*:{STATIC}/*:{ROOT}/*')
  258. while True:
  259. # take modification times
  260. source_mt = mtimes(args.source)
  261. source_mt.update(mtimes(LANGUAGES))
  262. static_mt = mtimes(STATIC)
  263. root_mt = mtimes(ROOT)
  264. sleep(WATCH_INTERVAL)
  265. # compare modification times and recompile if different
  266. new_source_mt = mtimes(args.source)
  267. new_source_mt.update(mtimes(LANGUAGES))
  268. if source_mt != new_source_mt:
  269. source_mt = new_source_mt
  270. save_generate(gen_layout, args, 'recompiling layout')
  271. new_root_mt = mtimes(ROOT)
  272. if root_mt != new_root_mt:
  273. root_mt = new_root_mt
  274. save_generate(gen_root, args, 'copying root files')
  275. new_static_mt = mtimes(STATIC)
  276. if static_mt != new_static_mt:
  277. static_mt = new_static_mt
  278. save_generate(gen_static, args, 'copying static files')
  279. if __name__ == '__main__':
  280. parser = argparse.ArgumentParser("Site generator")
  281. parser.add_argument('--watch', action='store_true', default=False)
  282. parser.add_argument('--with-index', action='store_true', default=False)
  283. parser.add_argument('--source', default='layout')
  284. parser.add_argument('--target', default='build')
  285. args = parser.parse_args()
  286. run(args)