blob: 6fc31dd66052feef716d8d5db31e8a563b6a49d7 [file] [log] [blame]
#!/usr/bin/env python3
#
# Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file
# for details. All rights reserved. Use of this source code is governed by a
# BSD-style license that can be found in the LICENSE file.
#
"""Tool used for rendering Dart Native Runtime wiki as HTML.
Usage: runtime/tools/wiki/build/build.py [--deploy]
If invoked without --deploy the tool would serve development version of the
wiki which supports fast edit-(auto)refresh cycle.
If invoked with --deploy it would build deployment version in the
/tmp/dart-vm-wiki directory.
"""
from __future__ import annotations
import argparse
import asyncio
import codecs
import coloredlogs
import glob
import jinja2
import logging
import markdown
import os
import posixpath
import re
import shutil
import subprocess
import sys
import time
import urllib
from aiohttp import web, WSCloseCode, WSMsgType
from http.server import HTTPServer, SimpleHTTPRequestHandler
from markdown.extensions.codehilite import CodeHiliteExtension
from pathlib import Path
from typing import Callable, Dict, Sequence
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
from xrefs import XrefExtension
# Configure logging to use colors.
coloredlogs.install(
level='INFO', fmt='%(asctime)s - %(message)s', datefmt='%H:%M:%S')
# Declare various directory paths.
# We expected to be located in runtime/tools/wiki/build.
TOOL_DIR = os.path.dirname(os.path.realpath(__file__))
SDK_DIR = os.path.relpath(os.path.join(TOOL_DIR, '..', '..', '..', '..'))
WIKI_SOURCE_DIR = os.path.join(SDK_DIR, 'runtime', 'docs')
STYLES_DIR = os.path.relpath(os.path.join(TOOL_DIR, '..', 'styles'))
STYLES_INCLUDES_DIR = os.path.join(STYLES_DIR, 'includes')
TEMPLATES_DIR = os.path.relpath(os.path.join(TOOL_DIR, '..', 'templates'))
TEMPLATES_INCLUDES_DIR = os.path.join(TEMPLATES_DIR, 'includes')
PAGE_TEMPLATE = 'page.html'
OUTPUT_DIR = '/tmp/dart-vm-wiki'
OUTPUT_CSS_DIR = os.path.join(OUTPUT_DIR, 'css')
# Clean output directory and recreate it.
shutil.rmtree(OUTPUT_DIR, ignore_errors=True)
os.makedirs(OUTPUT_DIR, exist_ok=True)
os.makedirs(OUTPUT_CSS_DIR, exist_ok=True)
# Parse incoming arguments.
parser = argparse.ArgumentParser()
parser.add_argument('--deploy', dest='deploy', action='store_true')
parser.set_defaults(deploy=False)
args = parser.parse_args()
is_dev_mode = not args.deploy
# Initialize jinja environment.
jinja2_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(TEMPLATES_DIR),
lstrip_blocks=True,
trim_blocks=True)
class Artifact:
"""Represents a build artifact with its dependencies and way of building."""
# Map of all discovered artifacts.
all: Dict[str, Artifact] = {}
# List of listeners which are notified whenever some artifact is rebuilt.
listeners = []
def __init__(self, output: str, inputs: Sequence[str]):
Artifact.all[output] = self
self.output = output
self.inputs = inputs
def depends_on(self, path: str) -> bool:
"""Check if this"""
return path in self.inputs
def build(self):
pass
@staticmethod
def build_all():
"""Build all artifacts."""
Artifact.build_matching(lambda obj: True)
@staticmethod
def build_matching(filter: Callable[[Artifact], bool]):
"""Build all artifacts matching the given filter."""
rebuilt = False
for _, artifact in Artifact.all.items():
if filter(artifact):
artifact.build()
rebuilt = True
# If any artifacts were rebuilt notify the listeners.
if rebuilt:
for listener in Artifact.listeners:
listener()
class Page(Artifact):
"""A single wiki Page (a markdown file)."""
def __init__(self, name: str):
self.name = name
super().__init__(
os.path.join(OUTPUT_DIR, name + '.html'),
[os.path.join(WIKI_SOURCE_DIR, name + '.md')])
def __repr__(self):
return 'Page(%s <- %s)' % (self.output, self.inputs[0])
def depends_on(self, path: str):
return path.startswith(TEMPLATES_INCLUDES_DIR) or super().depends_on(
path)
def load_markdown(self):
with open(self.inputs[0], 'r') as file:
content = file.read()
content = re.sub(r'(?<=[^\n])\n+<aside>', '<span class="aside">',
content)
content = re.sub(r'</aside>', '</span>', content)
return content
def build(self):
logging.info('Build %s from %s', self.output, self.inputs[0])
template = jinja2_env.get_template(PAGE_TEMPLATE)
result = template.render({
'dev':
is_dev_mode,
'body':
markdown.markdown(
self.load_markdown(),
extensions=[
'admonition', 'extra',
CodeHiliteExtension(), 'tables', 'pymdownx.superfences',
XrefExtension()
])
})
os.makedirs(os.path.dirname(self.output), exist_ok=True)
with codecs.open(self.output, "w", encoding='utf-8') as file:
file.write(result)
template_filename = template.filename # pytype: disable=attribute-error
self.inputs = [self.inputs[0], template_filename]
class Style(Artifact):
"""Stylesheet written in SASS which needs to be compiled to CSS."""
def __init__(self, name: str):
self.name = name
super().__init__(
os.path.join(OUTPUT_CSS_DIR, name + '.css'),
[os.path.join(STYLES_DIR, name + '.scss')])
def __repr__(self):
return 'Style(%s <- %s)' % (self.output, self.inputs[0])
def depends_on(self, path: str):
return path.startswith(STYLES_INCLUDES_DIR) or super().depends_on(path)
def build(self):
logging.info('Build %s from %s', self.output, self.inputs[0])
subprocess.call(['sass', self.inputs[0], self.output])
def find_images_directories():
"""Find all subdirectories called images within wiki."""
return [
f.relative_to(Path(WIKI_SOURCE_DIR)).as_posix()
for f in Path(WIKI_SOURCE_DIR).rglob('images')
]
def find_artifacts():
"""Find all wiki pages and styles and create corresponding Artifacts."""
Artifact.all = {}
for f in Path(WIKI_SOURCE_DIR).rglob('*.md'):
name = f.relative_to(Path(WIKI_SOURCE_DIR)).as_posix().rsplit('.', 1)[0]
Page(name)
for f in Path(STYLES_DIR).glob('*.scss'):
Style(f.stem)
def build_for_deploy():
logging.info('Building wiki for deployment into %s', OUTPUT_DIR)
Artifact.build_all()
for images_dir in find_images_directories():
src = os.path.join(WIKI_SOURCE_DIR, images_dir)
dst = os.path.join(OUTPUT_DIR, images_dir)
logging.info('Copying %s <- %s', dst, src)
shutil.rmtree(dst, ignore_errors=True)
shutil.copytree(src, dst)
# Some images directories contain OmniGraffle source files which need
# to be removed before
logging.info('Removing image source files (*.graffle)')
for graffle in Path(OUTPUT_DIR).rglob('*.graffle'):
logging.info('... removing %s', graffle.as_posix())
class ArtifactEventHandler(FileSystemEventHandler):
"""File system listener rebuilding artifacts based on changed paths."""
def __init__(self):
super().__init__()
def on_modified(self, event):
Artifact.build_matching(
lambda artifact: artifact.depends_on(event.src_path))
def serve_for_development():
logging.info('Serving wiki for development')
Artifact.build_all()
# Watch for file modifications and rebuild dependant artifacts when their
# dependencies change.
event_handler = ArtifactEventHandler()
observer = Observer()
observer.schedule(event_handler, TEMPLATES_DIR, recursive=False)
observer.schedule(event_handler, WIKI_SOURCE_DIR, recursive=True)
observer.schedule(event_handler, STYLES_DIR, recursive=True)
observer.start()
async def on_shutdown(app):
for ws in app['websockets']:
await ws.close(
code=WSCloseCode.GOING_AWAY, message='Server shutdown')
observer.stop()
observer.join()
async def handle_artifact(name):
source_path = os.path.join(OUTPUT_DIR, name)
logging.info('Handling source path %s for %s', source_path, name)
if source_path in Artifact.all:
return web.FileResponse(source_path)
else:
return web.HTTPNotFound()
async def handle_page(request):
name = request.match_info.get('name', 'index.html')
if name == '' or name.endswith('/'):
name = name + 'index.html'
return await handle_artifact(name)
async def handle_css(request):
name = request.match_info.get('name')
return await handle_artifact('css/' + name)
async def websocket_handler(request):
logging.info('websocket connection open')
ws = web.WebSocketResponse()
await ws.prepare(request)
loop = asyncio.get_event_loop()
def notify():
logging.info('requesting reload')
asyncio.run_coroutine_threadsafe(ws.send_str('reload'), loop)
Artifact.listeners.append(notify)
request.app['websockets'].append(ws)
try:
async for msg in ws:
if msg.type == WSMsgType.ERROR:
logging.error(
'websocket connection closed with exception %s',
ws.exception())
finally:
logging.info('websocket connection closing')
Artifact.listeners.remove(notify)
request.app['websockets'].remove(ws)
logging.info('websocket connection closed')
return ws
app = web.Application()
app['websockets'] = []
for images_dir in find_images_directories():
app.router.add_static('/' + images_dir,
os.path.join(WIKI_SOURCE_DIR, images_dir))
app.router.add_get('/ws', websocket_handler)
app.router.add_get('/css/{name}', handle_css)
app.router.add_get('/{name:[^{}]*}', handle_page)
app.on_shutdown.append(on_shutdown)
web.run_app(app, access_log_format='"%r" %s')
def main():
find_artifacts()
if is_dev_mode:
serve_for_development()
else:
build_for_deploy()
if __name__ == '__main__':
main()