sitegen.py 10 KB

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