-
-
Notifications
You must be signed in to change notification settings - Fork 932
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Create sphinx extension for integrating Flutter apps into the d…
…ocumentation site (#1393)
- Loading branch information
Showing
4 changed files
with
354 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,85 @@ | ||
|
||
button.flutter-app-button { | ||
background: #e2a73c; | ||
border: none; | ||
border-radius: 6px; | ||
box-shadow: 2px 2px 6px 0 black; | ||
color: black; | ||
cursor: pointer; | ||
font-family: var(--font-sans); | ||
font-size: 1.1em; | ||
font-weight: bold; | ||
line-height: 1em; | ||
min-height: 26pt; | ||
min-width: 120pt; | ||
} | ||
|
||
button.flutter-app-button:hover { | ||
background: #f3dc38; | ||
} | ||
|
||
button.flutter-app-button:active { | ||
background: white; | ||
left: 1px; | ||
position: relative; | ||
top: 1px; | ||
} | ||
|
||
button.flutter-app-button:after { | ||
content: '\f0da'; | ||
font-family: var(--font-awesome); | ||
margin-left: 4px; | ||
position: relative; | ||
top: 1px; | ||
} | ||
|
||
#flutter-app-overlay { | ||
background: #00000050; | ||
display: none; | ||
height: 100vh; | ||
left: 0; | ||
position: fixed; | ||
top: 0; | ||
width: 100vw; | ||
z-index: 10000; | ||
} | ||
|
||
#flutter-app-overlay.active { | ||
display: initial; | ||
} | ||
|
||
#flutter-app-overlay.active iframe { | ||
border: none; | ||
box-shadow: 0px 0px 9px 3px black; | ||
display: none; | ||
left: 50%; | ||
position: absolute; | ||
top: 50%; | ||
transform: translate(-50%, -50%); | ||
} | ||
|
||
#flutter-app-overlay.active iframe.active { | ||
display: initial; | ||
} | ||
|
||
#flutter-app-close-button { | ||
background: white; | ||
border: 1px solid black; | ||
border-radius: 50%; | ||
color: black; | ||
cursor: pointer; | ||
font-size: 20px; | ||
height: 30px; | ||
left: 90%; | ||
margin-left: -15px; | ||
margin-top: -15px; | ||
position: absolute; | ||
text-align: center; | ||
top: 10%; | ||
width: 30px; | ||
} | ||
|
||
#flutter-app-close-button:hover { | ||
background: #e38f13; | ||
color: white; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
'use strict'; | ||
|
||
/// Create an overlay with an iframe, the iframe's source is [url]. This also | ||
/// creates an (x) button to hide the overlay. | ||
function run_flutter_app(url) { | ||
let id = compute_iframe_id(url); | ||
if (!$('#flutter-app-overlay').length) { | ||
$('body').append($(` | ||
<div id="flutter-app-overlay"> | ||
<button id="flutter-app-close-button" onclick="close_flutter_app()">✖</button> | ||
</div>` | ||
)); | ||
} | ||
if (!$('#' + id).length) { | ||
$('#flutter-app-overlay').append($( | ||
`<iframe id="${id}" class="flutter-app" src="${url}"></iframe>` | ||
)); | ||
} | ||
$('#flutter-app-overlay').addClass('active'); | ||
$('#' + id).addClass('active'); | ||
} | ||
|
||
/// Handler for the (x) close button on an app iframe. | ||
function close_flutter_app() { | ||
$('#flutter-app-overlay iframe').removeClass('active'); | ||
$('#flutter-app-overlay').removeClass('active'); | ||
} | ||
|
||
/// Convert a URL such as '_static/app/tutorial1/index.html?page1' into a string | ||
/// that can be used as an id: 'app-tutorial1-index-html-page1'. | ||
function compute_iframe_id(url) { | ||
if (url.startsWith('_static/')) { | ||
url = url.substr(8); | ||
} | ||
let matches = url.matchAll(new RegExp('\\w+', 'g')); | ||
return Array.from(matches, m => m[0]).join('-'); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,228 @@ | ||
#!/usr/bin/env python | ||
import os | ||
import shutil | ||
import subprocess | ||
from docutils import nodes | ||
from docutils.parsers.rst import directives | ||
from sphinx.util.docutils import SphinxDirective | ||
from sphinx.util.logging import getLogger | ||
|
||
|
||
# ------------------------------------------------------------------------------ | ||
# `.. flutter-app::` directive | ||
# ------------------------------------------------------------------------------ | ||
|
||
class FlutterAppDirective(SphinxDirective): | ||
""" | ||
Embed Flutter apps into documentation pages. | ||
This extension allows inserting precompiled Flutter apps into the | ||
generated documentation. The app to be inserted has to be configured for | ||
compiling in 'web' mode. | ||
Example of usage in Markdown: | ||
```{flutter-app} | ||
:sources: ../../tetris-tutorial | ||
:page: page3 | ||
:show: popup | ||
``` | ||
The following arguments are supported: | ||
:sources: - the directory where the app is located, i.e. the directory | ||
with the pubspec.yaml file of the app. The path should be relative to | ||
the `doc/_sphinx` folder. | ||
:page: - an additional parameter that will be appended to the URL of the | ||
app. The app can retrieve this parameter by checking the property | ||
`window.location.search` (where `window` is from `dart:html`), and then | ||
display the content based on that. Thus, this parameter allows bundling | ||
multiple separate Flutter widgets into one compiled app. | ||
In addition, the "code" run mode will try to locate a file or a folder | ||
with the matching name. | ||
:show: - a list of one or more run modes, which could include "widget", | ||
"popup", and "code". Each of these modes produces a different output: | ||
"widget" - an iframe shown directly inside the docs page; | ||
"popup" - a [Run] button which opens the app to (almost) fullscreen; | ||
"code" - (NYI) opens a popup showing the code that was compiled. | ||
""" | ||
has_content = False | ||
required_arguments = 0 | ||
optional_arguments = 0 | ||
option_spec = { | ||
'sources': directives.unchanged, | ||
'page': directives.unchanged, | ||
'show': directives.unchanged, | ||
} | ||
# Static list of targets that were already compiled during the build | ||
COMPILED = [] | ||
|
||
def __init__(self, *args, **kwds): | ||
super().__init__(*args, **kwds) | ||
self.modes = None | ||
self.logger = None | ||
self.app_name = None | ||
self.source_dir = None | ||
self.source_build_dir = None | ||
self.target_dir = None | ||
self.html_dir = None | ||
|
||
def run(self): | ||
self.logger = getLogger('flutter-app') | ||
self._process_show_option() | ||
self._process_sources_option() | ||
self.source_build_dir = os.path.join(self.source_dir, 'build', 'web') | ||
self.app_name = os.path.basename(self.source_dir) | ||
self.html_dir = '_static/apps/' + self.app_name | ||
self.target_dir = os.path.abspath( | ||
os.path.join('..', '_build', 'html', self.html_dir)) | ||
self._ensure_compiled() | ||
|
||
page = self.options.get('page', '') | ||
iframe_url = self.html_dir + '/index.html?' + page | ||
result = [] | ||
if 'popup' in self.modes: | ||
result.append(Button( | ||
'', | ||
nodes.Text('Run'), | ||
classes=['flutter-app-button', 'popup'], | ||
onclick=f'run_flutter_app("{iframe_url}")', | ||
)) | ||
if 'code' in self.modes: | ||
pass | ||
if 'widget' in self.modes: | ||
result.append(IFrame(src=iframe_url)) | ||
return result | ||
|
||
def _process_show_option(self): | ||
argument = self.options.get('show') | ||
if argument: | ||
values = argument.split() | ||
for value in values: | ||
if value not in ['widget', 'popup', 'code']: | ||
raise self.error('Invalid :show: value ' + value) | ||
self.modes = values | ||
else: | ||
self.modes = ['widget'] | ||
|
||
def _process_sources_option(self): | ||
argument = self.options.get('sources', '') | ||
abspath = os.path.abspath(argument) | ||
if not argument: | ||
raise self.error('Missing required argument :sources:') | ||
if not os.path.isdir(abspath): | ||
raise self.error( | ||
f'sources=`{abspath}` does not exist or is not a directory') | ||
assert not abspath.endswith('/') | ||
self.source_dir = abspath | ||
|
||
def _ensure_compiled(self): | ||
need_compiling = ( | ||
('popup' in self.modes or 'widget' in self.modes) and | ||
self.source_dir not in FlutterAppDirective.COMPILED | ||
) | ||
if not need_compiling: | ||
return | ||
self.logger.info('Compiling Flutter app ' + self.app_name) | ||
self._compile_source() | ||
self._copy_compiled() | ||
self._create_index() | ||
self.logger.info(' + copied into ' + self.target_dir) | ||
assert os.path.isfile(self.target_dir + '/main.dart.js') | ||
assert os.path.isfile(self.target_dir + '/index.html') | ||
FlutterAppDirective.COMPILED.append(self.source_dir) | ||
|
||
def _compile_source(self): | ||
try: | ||
subprocess.run( | ||
['flutter', 'build', 'web', '--web-renderer', 'html'], | ||
stdout=subprocess.PIPE, | ||
stderr=subprocess.STDOUT, | ||
cwd=self.source_dir, | ||
check=True, | ||
) | ||
except subprocess.CalledProcessError as e: | ||
cmd = e.cmd.join(' ') | ||
raise self.severe( | ||
f'Command `{cmd}` returned with exit status {e.returncode}\n' + | ||
e.output.decode('utf-8'), | ||
) | ||
|
||
def _copy_compiled(self): | ||
assert os.path.isdir(self.source_build_dir) | ||
main_js = os.path.join(self.source_build_dir, 'main.dart.js') | ||
assets_dir = os.path.join(self.source_build_dir, 'assets') | ||
os.makedirs(self.target_dir, exist_ok=True) | ||
shutil.copy2(main_js, self.target_dir) | ||
if os.path.exists(assets_dir): | ||
shutil.copytree( | ||
src=assets_dir, | ||
dst=os.path.join(self.target_dir, 'assets'), | ||
dirs_exist_ok=True, | ||
) | ||
|
||
def _create_index(self): | ||
target_file = os.path.join(self.target_dir, 'index.html') | ||
with open(target_file, 'wt') as out: | ||
out.write('<!DOCTYPE html>\n') | ||
out.write('<html>\n<head>\n') | ||
out.write('<base href="/%s/">\n' % self.html_dir) | ||
out.write('<title>%s</title>\n' % self.app_name) | ||
out.write('</head>\n<body>\n') | ||
out.write('<script src="main.dart.js"></script>\n') | ||
out.write('</body>\n</html>\n') | ||
|
||
|
||
# ------------------------------------------------------------------------------ | ||
# Nodes | ||
# ------------------------------------------------------------------------------ | ||
|
||
class IFrame(nodes.Element, nodes.General): | ||
pass | ||
|
||
|
||
def visit_iframe(self, node): | ||
self.body.append(self.starttag(node, 'iframe', src=node.attributes['src'])) | ||
|
||
|
||
def depart_iframe(self, _): | ||
self.body.append('</iframe>') | ||
|
||
|
||
class Button(nodes.Element, nodes.General): | ||
pass | ||
|
||
|
||
def visit_button(self, node): | ||
attrs = {} | ||
if 'onclick' in node.attributes: | ||
attrs['onclick'] = node.attributes['onclick'] | ||
self.body.append(self.starttag(node, 'button', **attrs).strip()) | ||
|
||
|
||
def depart_button(self, _): | ||
self.body.append('</button>') | ||
|
||
|
||
# ------------------------------------------------------------------------------ | ||
# Extension setup | ||
# ------------------------------------------------------------------------------ | ||
|
||
def setup(app): | ||
base_dir = os.path.dirname(__file__) | ||
target_dir = os.path.abspath('../_build/html/_static/') | ||
os.makedirs(target_dir, exist_ok=True) | ||
shutil.copy(os.path.join(base_dir, 'flutter_app.js'), target_dir) | ||
shutil.copy(os.path.join(base_dir, 'flutter_app.css'), target_dir) | ||
|
||
app.add_node(IFrame, html=(visit_iframe, depart_iframe)) | ||
app.add_node(Button, html=(visit_button, depart_button)) | ||
app.add_directive('flutter-app', FlutterAppDirective) | ||
app.add_js_file('flutter_app.js') | ||
app.add_css_file('flutter_app.css') | ||
return { | ||
'parallel_read_safe': False, | ||
'parallel_write_safe': False, | ||
'env_version': 1, | ||
} |