├── tests ├── __init__.py ├── fixtures │ ├── __init__.py │ ├── script │ │ ├── __init__.py │ │ ├── app.py │ │ └── a_script.py │ ├── click_fixtures.py │ └── flask_fixtures.py ├── conftest.py ├── test_request_parsing.py ├── test_flask_get.py ├── test_flask_post.py └── test_click_web.py ├── click_web ├── resources │ ├── __init__.py │ ├── index.py │ ├── cmd_form.py │ ├── input_fields.py │ └── cmd_exec.py ├── exceptions.py ├── static │ ├── panes.js │ ├── copy_to_clipboard.js │ ├── panes.css │ ├── open_form.js │ ├── click_web.css │ ├── post_and_read.js │ ├── split.js │ └── pure.css ├── web_click_types.py ├── templates │ ├── show_tree.html.j2 │ ├── command_form.html.j2 │ └── form_macros.html.j2 └── __init__.py ├── example ├── custom │ ├── templates │ │ └── head.html.j2 │ ├── app.py │ └── static │ │ └── custom.css ├── basic │ └── app.py ├── digest_auth │ └── app.py └── example_command.py ├── MANIFEST.in ├── doc ├── click-web-demo.gif └── click-web-example.png ├── AUTHORS.rst ├── setup.cfg ├── check.sh ├── LICENSE ├── .gitignore ├── CONTRIBUTING.rst ├── setup.py ├── .github └── workflows │ └── python-publish.yml └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /click_web/resources/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixtures/script/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/custom/templates/head.html.j2: -------------------------------------------------------------------------------- 1 |
2 | Some custom header stuff 3 |
-------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include click_web/templates * 2 | recursive-include click_web/static * -------------------------------------------------------------------------------- /doc/click-web-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrik-corneliusson/click-web/HEAD/doc/click-web-demo.gif -------------------------------------------------------------------------------- /doc/click-web-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrik-corneliusson/click-web/HEAD/doc/click-web-example.png -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ==================== 2 | Project contributors 3 | ==================== 4 | 5 | * Fredrik Corneliusson -------------------------------------------------------------------------------- /click_web/exceptions.py: -------------------------------------------------------------------------------- 1 | class ClickWebException(Exception): 2 | pass 3 | 4 | 5 | class CommandNotFound(ClickWebException): 6 | pass 7 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from tests.fixtures.click_fixtures import cli, ctx, loaded_script_module 3 | from tests.fixtures.flask_fixtures import app, client 4 | -------------------------------------------------------------------------------- /example/basic/app.py: -------------------------------------------------------------------------------- 1 | from click_web import create_click_web_app 2 | from example import example_command 3 | 4 | app = create_click_web_app(example_command, example_command.cli) 5 | -------------------------------------------------------------------------------- /tests/fixtures/script/app.py: -------------------------------------------------------------------------------- 1 | from click_web import create_click_web_app 2 | from tests.fixtures.script import a_script 3 | 4 | app = create_click_web_app(a_script, a_script.cli) 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | norecursedirs = venv build dist 3 | 4 | [flake8] 5 | exclude = venv,build,dist 6 | max-line-length = 119 7 | 8 | [isort] 9 | line_length = 79 10 | skip = venv,build,dist 11 | -------------------------------------------------------------------------------- /check.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Util command to run tests and code lint. 3 | 4 | pytest && isort . && flake8 5 | retVal=$? 6 | if [ $retVal -eq 0 ]; then 7 | echo "All checks OK!" 8 | else 9 | echo "Check failed, please fix above errors." 10 | 11 | fi 12 | exit 13 | -------------------------------------------------------------------------------- /click_web/static/panes.js: -------------------------------------------------------------------------------- 1 | function initPanes() { 2 | // https://split.js.org/ 3 | Split(['#left-pane', '#right-pane'], { 4 | sizes: [25, 75], 5 | gutterSize: 5, 6 | cursor: 'row-resize', 7 | }); 8 | 9 | } 10 | 11 | document.addEventListener("DOMContentLoaded", initPanes); -------------------------------------------------------------------------------- /click_web/static/copy_to_clipboard.js: -------------------------------------------------------------------------------- 1 | function copyOutputToClipboard() { 2 | var range = document.createRange(); 3 | range.selectNode(document.getElementById("output")); 4 | window.getSelection().removeAllRanges(); // clear current selection 5 | window.getSelection().addRange(range); // to select text 6 | document.execCommand("copy"); 7 | window.getSelection().removeAllRanges();// to deselect 8 | } 9 | -------------------------------------------------------------------------------- /tests/fixtures/click_fixtures.py: -------------------------------------------------------------------------------- 1 | import click 2 | import pytest 3 | 4 | 5 | @pytest.fixture() 6 | def loaded_script_module(): 7 | import tests.fixtures.script.a_script 8 | yield tests.fixtures.script.a_script 9 | 10 | 11 | @pytest.fixture() 12 | def cli(loaded_script_module): 13 | yield loaded_script_module.cli 14 | 15 | 16 | @pytest.fixture() 17 | def ctx(cli): 18 | with click.Context(cli, info_name=cli, parent=None) as ctx: 19 | yield ctx 20 | -------------------------------------------------------------------------------- /tests/fixtures/flask_fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from click_web import create_click_web_app 4 | 5 | _app = None 6 | 7 | 8 | @pytest.fixture 9 | def app(loaded_script_module, cli): 10 | global _app 11 | if _app is None: 12 | _app = create_click_web_app(loaded_script_module, cli) 13 | with _app.app_context(): 14 | yield _app 15 | 16 | 17 | @pytest.fixture 18 | def client(app): 19 | with app.test_client() as client: 20 | yield client 21 | -------------------------------------------------------------------------------- /example/custom/app.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from click_web import create_click_web_app 4 | from example import example_command 5 | 6 | # create app as normal 7 | app = create_click_web_app(example_command, example_command.cli) 8 | 9 | # Expect any custom static and template folder to be located in same folder as this script 10 | # Make custom folder reachable by "custom/static" url path and add templates folder 11 | custom_folder_blueprint = Blueprint('custom', __name__, 12 | static_url_path='/custom/static', 13 | static_folder='static', 14 | template_folder='templates') 15 | app.register_blueprint(custom_folder_blueprint) 16 | 17 | # Set CUSTOM_CSS in flask config so click-web will use it. 18 | app.config['CUSTOM_CSS'] = 'custom.css' 19 | -------------------------------------------------------------------------------- /click_web/static/panes.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | } 5 | 6 | body { 7 | /*padding: 8px;*/ 8 | background-color: #F6F6F6; 9 | box-sizing: border-box; 10 | } 11 | 12 | .split { 13 | -webkit-box-sizing: border-box; 14 | -moz-box-sizing: border-box; 15 | box-sizing: border-box; 16 | overflow-y: auto; 17 | overflow-x: auto; 18 | } 19 | 20 | .content { 21 | border: 1px solid #C0C0C0; 22 | box-shadow: inset 0 1px 2px #e4e4e4; 23 | background-color: #fff; 24 | padding: 15px; 25 | } 26 | 27 | .gutter { 28 | background-color: transparent; 29 | background-repeat: no-repeat; 30 | background-position: 50%; 31 | } 32 | 33 | .gutter.gutter-horizontal { 34 | cursor: col-resize; 35 | } 36 | 37 | .gutter.gutter-vertical { 38 | cursor: row-resize; 39 | } 40 | 41 | .split.split-horizontal, 42 | .gutter.gutter-horizontal { 43 | height: 100%; 44 | float: left; 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 fredrik-corneliusson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /example/custom/static/custom.css: -------------------------------------------------------------------------------- 1 | /* An example of how to override the default css used by click-web */ 2 | 3 | body { 4 | font-family: Arial, Verdana, Tahoma; 5 | font-size: 16px; 6 | font-weight: 500; 7 | color: rgba(0, 0, 0, 0.87); 8 | background-color: #999999; 9 | } 10 | 11 | .content { 12 | border: 1px solid #C0C0C0; 13 | box-shadow: inset 0 1px 2px #e4e4e4; 14 | background-color: #AFAFFF; 15 | padding: 15px; 16 | } 17 | 18 | .command-header { 19 | color: gray; 20 | font-size: small; 21 | } 22 | 23 | .command-tree .help:before { 24 | content: ""; 25 | } 26 | 27 | .command-tree .help { 28 | font-size: small; 29 | color: whitesmoke; 30 | display: block; 31 | margin-left: 0px; 32 | } 33 | 34 | .command-tree .command-selected { 35 | font-weight: normal; 36 | font-style: italic; 37 | background: lightgray; 38 | 39 | } 40 | 41 | .script-output { 42 | border-color: rgba(0, 255, 0, 0.50); 43 | color: lightgreen; 44 | background-color: black; 45 | display: block; 46 | width: fit-content; 47 | min-width: 95%; 48 | unicode-bidi: embed; 49 | font-family: monospace; 50 | white-space: pre; 51 | margin-bottom: 1em; 52 | padding: 7px; 53 | } 54 | 55 | .btn-copy { 56 | color: white; 57 | } -------------------------------------------------------------------------------- /click_web/static/open_form.js: -------------------------------------------------------------------------------- 1 | function openCommand(cmdUrl, updateState, menuItem) { 2 | if (REQUEST_RUNNING) { 3 | let leave = confirm("Command is still running. Leave anyway?"); 4 | if (leave) { 5 | // hack as this is global on page and if we leave the other command cannot run. 6 | // TODO: fetch will continue to run. 7 | // Do a real abort of fetch: https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort 8 | REQUEST_RUNNING = false; 9 | } else { 10 | return; 11 | } 12 | } 13 | 14 | let formDiv = document.getElementById('form-div'); 15 | // if (updateState) { 16 | // 17 | // history.pushState({'cmdUrl': cmdUrl}, null, cmdUrl); 18 | // } 19 | fetch(cmdUrl) 20 | .then(function(response) { 21 | selectMenuItem(menuItem); 22 | return response.text(); 23 | }) 24 | .then(function(theFormHtml) { 25 | formDiv.innerHTML = theFormHtml; 26 | }); 27 | } 28 | 29 | function selectMenuItem(menuItem) { 30 | var x = document.getElementsByClassName("command-selected"); 31 | var i; 32 | for (i = 0; i < x.length; i++) { 33 | x[i].classList.remove('command-selected'); 34 | } 35 | menuItem.classList.add('command-selected'); 36 | } -------------------------------------------------------------------------------- /click_web/web_click_types.py: -------------------------------------------------------------------------------- 1 | """ 2 | Extra click types that could be useful in a web context as they have corresponding HTML form input type. 3 | 4 | The custom web click types need only be imported into the main script, not the app.py that flask runs. 5 | 6 | Example usage in your click command: 7 | \b 8 | from click_web.web_click_types import EMAIL_TYPE 9 | @cli.command() 10 | @click.option("--the_email", type=EMAIL_TYPE) 11 | def email(the_email): 12 | click.echo(f"{the_email} is a valid email syntax.") 13 | 14 | """ 15 | import re 16 | 17 | import click 18 | 19 | 20 | class EmailParamType(click.ParamType): 21 | name = 'email' 22 | EMAIL_REGEX = re.compile(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)") 23 | 24 | def convert(self, value, param, ctx): 25 | if self.EMAIL_REGEX.match(value): 26 | return value 27 | else: 28 | self.fail(f'{value} is not a valid email', param, ctx) 29 | 30 | 31 | class PasswordParamType(click.ParamType): 32 | name = "password" 33 | 34 | def convert(self, value, param, ctx): 35 | return value 36 | 37 | 38 | class TextAreaParamType(click.ParamType): 39 | name = "textarea" 40 | 41 | def convert(self, value, param, ctx): 42 | return value 43 | 44 | 45 | EMAIL_TYPE = EmailParamType() 46 | PASSWORD_TYPE = PasswordParamType() 47 | TEXTAREA_TYPE = TextAreaParamType() 48 | -------------------------------------------------------------------------------- /tests/test_request_parsing.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | import flask 4 | import pytest 5 | 6 | from click_web.resources.cmd_exec import CommandLineForm 7 | 8 | app = flask.Flask(__name__) 9 | 10 | 11 | @pytest.mark.parametrize( 12 | 'data, expected', 13 | [ 14 | ({ 15 | '0.0.option.text.1.text.--an-option': 'option-value', 16 | '0.1.option.text.1.text.--another-option': 'another-option-value', 17 | '1.0.option.text.1.text.--option-for-other-command': 'some value', 18 | '1.1.option.text.1.text.-short-opt': 'short option value' 19 | }, (['--an-option', 'option-value', '--another-option', 'another-option-value', 20 | 'command2', '--option-for-other-command', 'some value', "-short-opt", 'short option value']), 21 | ), # noqa 22 | (OrderedDict(( 23 | ('0.1.option.text.1.text.--another-option', 'another-option-value'), 24 | ('1.1.option.text.1.text.-short-opt', 'short option value'), 25 | ('0.0.option.text.1.text.--an-option', 'option-value'), 26 | ('1.0.option.text.1.text.--option-for-other-command', 'some value') 27 | )), (['--an-option', 'option-value', '--another-option', 'another-option-value', 28 | 'command2', '--option-for-other-command', 'some value', "-short-opt", 'short option value']), 29 | ), 30 | ]) 31 | def test_form_post_to_commandline_arguments(data, expected): 32 | with app.test_request_context('/command', data=data): 33 | command_line = CommandLineForm('/some/script.py', commands=["command2"]) 34 | assert command_line.get_commandline()[2:] == expected 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Environments 87 | .env 88 | .venv 89 | env/ 90 | venv/ 91 | ENV/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /click_web/resources/index.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from typing import Union 3 | 4 | import click 5 | from flask import render_template 6 | 7 | import click_web 8 | 9 | 10 | def index(): 11 | with click.Context(click_web.click_root_cmd, info_name=click_web.click_root_cmd.name, parent=None) as ctx: 12 | return render_template('show_tree.html.j2', ctx=ctx, tree=_click_to_tree(ctx, click_web.click_root_cmd)) 13 | 14 | 15 | def _click_to_tree(ctx: click.Context, node: Union[click.Command, click.MultiCommand], ancestors: list = None): 16 | """ 17 | Convert a click root command to a tree of dicts and lists 18 | :return: a json like tree 19 | """ 20 | if ancestors is None: 21 | ancestors = [] 22 | res_childs = [] 23 | res = OrderedDict() 24 | res['is_group'] = isinstance(node, click.core.MultiCommand) 25 | if res['is_group']: 26 | # a group, recurse for every child 27 | children = [node.get_command(ctx, key) for key in node.list_commands(ctx)] 28 | # Sort so commands comes before groups 29 | children = sorted(children, key=lambda c: isinstance(c, click.core.MultiCommand)) 30 | for child in children: 31 | res_childs.append(_click_to_tree(ctx, child, ancestors[:] + [node, ])) 32 | 33 | res['name'] = node.name 34 | 35 | # Do not include any preformatted block (\b) for the short help. 36 | res['short_help'] = node.get_short_help_str().split('\b')[0] 37 | res['help'] = node.help 38 | path_parts = ancestors + [node] 39 | root = click_web._flask_app.config['APPLICATION_ROOT'].rstrip('/') 40 | res['path'] = root + '/' + '/'.join(p.name for p in path_parts) 41 | if res_childs: 42 | res['childs'] = res_childs 43 | return res 44 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ======================= 2 | Contribution guidelines 3 | ======================= 4 | 5 | 6 | Running tests 7 | ============= 8 | 9 | Install development dependencies:: 10 | 11 | pip install -e .[dev] 12 | 13 | Running tests and code linting:: 14 | 15 | ./check 16 | 17 | Creating a release 18 | ================== 19 | 20 | **NOTE: this release instruction is deprecated, click-web now uses github to make releases.** 21 | 22 | * Checkout the ``master`` branch. 23 | * Pull the latest changes from ``origin``. 24 | * Increment the version number (in setup.py). 25 | * If needed update the ``AUTHORS.rst`` file with new contributors. 26 | * Run ./check.sh to verify that all unittests and code checks pass. 27 | * Commit everything and make sure the working tree is clean. 28 | * Push everything to github:: 29 | 30 | git push origin master 31 | 32 | * Build release:: 33 | 34 | python setup.py sdist bdist_wheel 35 | 36 | * Tag the release:: 37 | 38 | git tag -a "v$(python setup.py --version)" -m "$(python setup.py --name) release version $(python setup.py --version)" 39 | 40 | * Push everything to github:: 41 | 42 | git push --tags origin master 43 | 44 | * Publish on test.pypi:: 45 | 46 | twine upload --repository-url https://test.pypi.org/legacy/ dist/* -u fredrik-corneliusson -p 'some_secret_password' 47 | 48 | * Verify release looks ok at `test.pypi `_ 49 | 50 | * Install package from test.pypi in a clean virtual env and verify that it works:: 51 | 52 | pip install --extra-index-url https://test.pypi.org/simple/ click-web --upgrade 53 | 54 | * Deploy release to production pypi:: 55 | 56 | twine upload dist/* -u fredrik-corneliusson -p 'some_secret_password' 57 | 58 | * Verify release on `production pypi `_:: 59 | 60 | pip install click-web --upgrade 61 | 62 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | SHORT_DESCRIPTION = 'Serve click scripts over the web with minimal effort.' 4 | 5 | # Use the README as the long description 6 | with open('README.rst') as f: 7 | LONG_DESCRIPTION = f.read() 8 | 9 | requirements = [ 10 | 'click>=8.0', 11 | 'Flask>=2.3.2', 12 | 'Jinja2>=3.1.3', 13 | 'flask_httpauth>=3.2.4' 14 | ] 15 | 16 | dev_requirements = [ 17 | 'pytest>=6.2', 18 | 'flake8>=3.9', 19 | 'beautifulsoup4>=4.9', 20 | 'isort>=5.8', 21 | 'twine>=3.4', 22 | 'wheel' 23 | ] 24 | 25 | 26 | setup( 27 | name='click-web', 28 | version='0.8.6', 29 | url='https://github.com/fredrik-corneliusson/click-web', 30 | author='Fredrik Corneliusson', 31 | author_email='fredrik.corneliusson@gmail.com', 32 | description=SHORT_DESCRIPTION, 33 | long_description=LONG_DESCRIPTION, 34 | license='MIT', 35 | include_package_data=True, 36 | packages=find_packages(), 37 | zip_safe=False, 38 | python_requires='>=3.8', 39 | install_requires=requirements, 40 | dependency_links=[], 41 | extras_require={ 42 | 'dev': dev_requirements 43 | }, 44 | classifiers=[ 45 | 'Development Status :: 3 - Alpha', 46 | 'Intended Audience :: Developers', 47 | 'License :: OSI Approved :: MIT License', 48 | 'Operating System :: OS Independent', 49 | 'Topic :: Software Development :: User Interfaces', 50 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 51 | 'Topic :: System :: Shells', 52 | 'Topic :: Utilities', 53 | 'Programming Language :: Python', 54 | 'Programming Language :: Python :: 3', 55 | 'Programming Language :: Python :: 3.8', 56 | 'Programming Language :: Python :: 3.9', 57 | 'Programming Language :: Python :: 3.10', 58 | 'Programming Language :: Python :: 3.11', 59 | ] 60 | ) 61 | -------------------------------------------------------------------------------- /example/digest_auth/app.py: -------------------------------------------------------------------------------- 1 | """ 2 | An Example click-web flask app that forces user login before access to click-web command using digest auth. 3 | Please note this is just rudimentary security, unless you serve flask under HTTPS this will not protect data sent and 4 | and is susceptible to Man in the middle attacks. 5 | """ 6 | 7 | from flask_httpauth import HTTPDigestAuth 8 | 9 | from click_web import create_click_web_app 10 | from example import example_command 11 | 12 | # just a dict with username as key and password as value 13 | users = { 14 | 'user': 'password', 15 | 'another_user': 'password' 16 | } 17 | 18 | 19 | def _get_password_callback(username): 20 | """For a username return cleartext password""" 21 | app.logger.info(f'Verifying user: {username}') 22 | 23 | if username in users: 24 | return users[username] 25 | 26 | return None 27 | 28 | 29 | def setup_authentication(app, get_pw_callback): 30 | """ 31 | This sets up HTTPDigestAuth login to a flask app before each request. 32 | :param app: The flask app to add HTTPDigestAuth to. 33 | :param get_pw_callback: A function that takes a username and returns cleartext password. 34 | :return: Nothing 35 | """ 36 | auth = HTTPDigestAuth() 37 | 38 | @auth.login_required 39 | def _assert_auth_before_request(): 40 | """ 41 | Run before each request, relies on the fact that the decorator function @auth.login_required 42 | will not run this code if it fails, and if it did not stop just pass thru call by returning None 43 | """ 44 | app.logger.info(f'User: {auth.username()}') 45 | return None 46 | 47 | app.logger.info(f'Setting up {app} to require login...') 48 | auth.get_password(get_pw_callback) 49 | app.before_request(_assert_auth_before_request) 50 | 51 | 52 | # create app 53 | app = create_click_web_app(example_command, example_command.cli) 54 | 55 | # Set this to a random key and keep it secret, example from what you get from os.urandom(12) 56 | app.secret_key = b'sbnh&%r%&h/nTHFdgsdfdwekfjkwsfjkhw345rnmklb4564' 57 | 58 | # This adds authentication for all requests to flask app. 59 | setup_authentication(app, get_pw_callback=_get_password_callback) 60 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | name: Build distribution 📦 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Set up Python 13 | uses: actions/setup-python@v5 14 | with: 15 | python-version: "3.x" 16 | - name: Install pypa/build 17 | run: >- 18 | python3 -m 19 | pip install 20 | build 21 | --user 22 | - name: Build a binary wheel and a source tarball 23 | run: python3 -m build 24 | - name: Store the distribution packages 25 | uses: actions/upload-artifact@v3 26 | with: 27 | name: python-package-distributions 28 | path: dist/ 29 | 30 | publish-to-pypi: 31 | name: >- 32 | Publish Python 🐍 distribution 📦 to PyPI 33 | if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes 34 | needs: 35 | - build 36 | runs-on: ubuntu-latest 37 | environment: 38 | name: pypi 39 | url: https://pypi.org/p/click-web 40 | permissions: 41 | id-token: write # IMPORTANT: mandatory for trusted publishing 42 | 43 | steps: 44 | - name: Download all the dists 45 | uses: actions/download-artifact@v3 46 | with: 47 | name: python-package-distributions 48 | path: dist/ 49 | - name: Publish distribution 📦 to PyPI 50 | uses: pypa/gh-action-pypi-publish@release/v1 51 | 52 | publish-to-testpypi: 53 | name: Publish Python 🐍 distribution 📦 to TestPyPI 54 | needs: 55 | - build 56 | runs-on: ubuntu-latest 57 | 58 | environment: 59 | name: testpypi 60 | url: https://test.pypi.org/p/click-web 61 | 62 | permissions: 63 | id-token: write # IMPORTANT: mandatory for trusted publishing 64 | 65 | steps: 66 | - name: Download all the dists 67 | uses: actions/download-artifact@v3 68 | with: 69 | name: python-package-distributions 70 | path: dist/ 71 | - name: Publish distribution 📦 to TestPyPI 72 | uses: pypa/gh-action-pypi-publish@release/v1 73 | with: 74 | repository-url: https://test.pypi.org/legacy/ -------------------------------------------------------------------------------- /click_web/templates/show_tree.html.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ tree.name }} 5 | 6 | 7 | 8 | {%- if config['CUSTOM_CSS'] %} 9 | {% endif %} 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {% include "head.html.j2" ignore missing %} 21 | 22 |
23 |
24 |

{{ tree.help }}

25 | 26 |
    27 | {%- for command in tree.childs recursive %} 28 |
  • 29 | {% if command.is_group %} 30 | {{ command.name|title }} 31 |
    {{ command.short_help }}
    32 | {% else %} 33 | {{ command.name }} 35 | {% endif %} 36 | {%- if command.childs -%} 37 | 38 | {%- endif %}
  • 39 | {%- endfor %} 40 |
41 | 42 |
43 |
44 | 45 |
46 | 47 |
48 |

Select a command in left pane.

49 |
50 |
51 | 52 | 53 | -------------------------------------------------------------------------------- /click_web/static/click_web.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Arial, Verdana, Tahoma; 3 | font-size: 14px; 4 | font-weight: 400; 5 | line-height: 1.429; 6 | color: rgba(0, 0, 0, 0.87); 7 | } 8 | 9 | .command-tree { 10 | } 11 | 12 | .command-tree .help:before { 13 | content: " – "; 14 | } 15 | 16 | .command-tree .help { 17 | margin-left: 0px; 18 | } 19 | 20 | .command-tree .command-selected { 21 | font-weight: bold; 22 | 23 | } 24 | 25 | h1 { 26 | color: rgba(0, 0, 0, 0.54); 27 | } 28 | 29 | .command-title { 30 | margin-top: 1px; 31 | } 32 | 33 | .command-title-parents { 34 | margin-bottom: 1px; 35 | font-size: small; 36 | color: rgba(0, 0, 0, 0.50); 37 | } 38 | 39 | .command-help { 40 | font-size: large; 41 | } 42 | 43 | .pure-form .command-header { 44 | color: rgba(0, 0, 0, 0.60); 45 | font-size: small; 46 | } 47 | 48 | .help { 49 | font-size: small; 50 | color: gray; 51 | display: inline; 52 | margin-left: 10px; 53 | } 54 | 55 | input[type=number] { 56 | font-family: monospace; 57 | } 58 | 59 | .back-links { 60 | display: block; 61 | margin-bottom: 1em; 62 | float: right; 63 | } 64 | 65 | .command-line { 66 | display: block; 67 | unicode-bidi: embed; 68 | font-family: monospace; 69 | white-space: pre; 70 | margin-bottom: 1em; 71 | overflow: hidden; 72 | } 73 | 74 | 75 | .script-output-wrapper { 76 | display: block; 77 | margin-bottom: 1em; 78 | border-style: dashed; 79 | border-color: rgba(0, 0, 0, 0.30); 80 | width: fit-content; 81 | min-width: 98%; 82 | min-height: 30px; 83 | padding: 7px; 84 | } 85 | 86 | .script-output { 87 | font-family: monospace; 88 | white-space: pre; 89 | } 90 | 91 | .script-exit { 92 | font-weight: bold; 93 | } 94 | 95 | .script-exit-ok { 96 | color: green; 97 | } 98 | 99 | .script-exit-error { 100 | color: palevioletred; 101 | } 102 | 103 | .btn-copy { 104 | background: transparent; 105 | border: none; 106 | opacity: 0.7; 107 | padding: .25rem .5rem; 108 | position: -webkit-sticky; 109 | position: sticky; 110 | float: right; 111 | top: 0px; 112 | } 113 | 114 | a:link { 115 | text-decoration: none; 116 | } 117 | 118 | a:visited { 119 | color: #0000FF 120 | } 121 | 122 | a:hover { 123 | color: #9090FF 124 | } 125 | 126 | a:active { 127 | color: #0090FF 128 | } 129 | 130 | a:focus { 131 | color: #0000FF; 132 | } 133 | -------------------------------------------------------------------------------- /tests/test_flask_get.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from bs4 import BeautifulSoup 3 | 4 | 5 | def test_get_index(app, client): 6 | resp = client.get('/') 7 | assert resp.status_code == 200 8 | assert b'the root command' in resp.data 9 | 10 | 11 | @pytest.mark.parametrize( 12 | 'command_path, response_code, expected_msg, expected_form_ids', 13 | [ 14 | 15 | ('/cli/simple-no-params-command', 200, b'>Simple-No-Params-CommandCommand-With-Option-And-ArgumentA-Sub-Group-CommandCommand-With-Input-FolderCommand-With-Output-FolderCommand-With-Flag-Without-Off-OptionCommand-With-Variadic-Args{{ levels[1:-1]| join(' - ', attribute='command.name')|title }} 3 |

{{ command.name|title }}

4 |
{{ command.html_help }}
5 | 6 |
12 | {% set command_list = [] %} 13 | {% for level in levels %} 14 |
15 | {% if loop.index == 1 %} 16 | {# do not print root command as it most likely is juse "cli" #} 17 | 18 | {% elif not level.fields %} 19 | {# It is just a command group without any options do not create it's own section #} 20 | {% do command_list.append(level.command.name) %} 21 | {% else %} 22 | {% do command_list.append(level.command.name) %} 23 | {{ command_list | join(' ') }} 24 | {% do command_list.clear() %} 25 | {% endif %} 26 | 27 | {% for field in level.fields %} 28 |
29 |
30 | {% if field.nargs == -1 %} 31 | {{ macros.add_variadic_field_input(field) }} 32 | {% else %} 33 | 36 | {% for i in range(field.nargs) %} 37 | {{ macros.add_field_input(field, i) }} 38 | {% endfor %} 39 |
40 | {{ field.desc|default('', true)|capitalize }}
41 | 42 | {% endif %} 43 |
44 |
45 | {% endfor %} 46 |
47 | {% endfor %} 48 | 49 |
50 | 51 | 52 | 53 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /click_web/templates/form_macros.html.j2: -------------------------------------------------------------------------------- 1 | {% macro add_field_input(field, narg) -%} 2 | {% if field.type == 'option' %} 3 | {{ add_option_field(field) }} 4 | {% else %} 5 | {% if field.type == "checkbox" and field.checked %} 6 | {# 7 | Hack to work with flags that are default on. 8 | As checkbox is only sent down when checked we duplicate 9 | hidden field that is always sent but with empty value. 10 | https://stackoverflow.com/a/1992745/1306577 #} 11 | 12 | {% endif %} 13 | {% if field.type == "textarea" %} 14 | 19 | {% else %} 20 | 21 | 1 %} value="{{ field.value[narg]|default('', true) }}" 29 | {# only one option so just use the value set as default #} 30 | {% else %} value="{{ field.value|default('', true) }}" {% endif %} 31 | 32 | {{ field.checked }} 33 | {{ 'required' if field.required else '' }} 34 | 35 | 36 | > 37 | {% endif %} 38 | {% endif %} 39 | 40 | {%- endmacro %} 41 | 42 | {% macro add_option_field(field) -%} 43 | 51 | {%- endmacro %} 52 | 53 | 54 | {% macro add_variadic_field_input(field) -%} 55 | 58 | {% if field.type == 'option' or field.type == "checkbox" %} 59 | VARIARDIC OPTIONS OR CHECKBOXES ARE NOT SUPPORTED 60 | {% else %} 61 | 62 | {% endif %} 63 |
64 | (Each line will be passed as separate argument) 65 | {{ field.desc|default('', true)|capitalize }} 66 |
67 | 68 | {%- endmacro %} 69 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | click-web 2 | ========= 3 | 4 | Serve click scripts over the web with minimal effort. 5 | 6 | .. image:: https://github.com/fredrik-corneliusson/click-web/raw/master/doc/click-web-example.png 7 | :width: 700 8 | :alt: Example screenshot 9 | 10 | Usage 11 | ----- 12 | 13 | See this demo `screen capture`_. 14 | 15 | .. _screen capture: https://github.com/fredrik-corneliusson/click-web/raw/master/doc/click-web-demo.gif 16 | 17 | Take an existing click script, like this one: 18 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 19 | 20 | ``example_command.py`` 21 | 22 | :: 23 | 24 | import click 25 | import time 26 | 27 | @click.group() 28 | def cli(): 29 | 'A stupid script to test click-web' 30 | pass 31 | 32 | @cli.command() 33 | @click.option("--delay", type=float, default=0.01, help='delay for every line print') 34 | @click.argument("lines", default=10, type=int) 35 | def print_rows(lines, delay): 36 | 'Print lines with a delay' 37 | click.echo(f"writing: {lines} with {delay}") 38 | for i in range(lines): 39 | click.echo(f"Hello row: {i}") 40 | time.sleep(delay) 41 | 42 | if __name__ == '__main__': 43 | cli() 44 | 45 | Create a minimal script to run with flask 46 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 47 | 48 | ``app.py`` 49 | 50 | :: 51 | 52 | from click_web import create_click_web_app 53 | import example_command 54 | 55 | app = create_click_web_app(example_command, example_command.cli) 56 | 57 | Running example app: 58 | ~~~~~~~~~~~~~~~~~~~~ 59 | 60 | In Bash: 61 | 62 | :: 63 | 64 | export FLASK_ENV=development 65 | export FLASK_APP=app.py 66 | flask run 67 | 68 | *Caution*: If you plan to serve publicly make sure you setup security (SSL, login etc.) 69 | See `Authentication`_ 70 | 71 | Authentication 72 | ============== 73 | For an example of how to secure using http digest auth see the `auth example`_. 74 | 75 | Note: There is no permission system and all logged in users can access everything. 76 | If you plan to deploy in an open environment make sure to setup HTTPS. 77 | 78 | .. _auth example: https://github.com/fredrik-corneliusson/click-web/blob/master/example/digest_auth/app.py 79 | 80 | Custom Styling 81 | ============== 82 | For an example of how to customize styling using CSS and add extra page head or footer see the `custom example`_. 83 | 84 | .. _custom example: https://github.com/fredrik-corneliusson/click-web/blob/master/example/custom/app.py 85 | 86 | 87 | Unsupported click features 88 | ========================== 89 | 90 | It has only been tested with basic click features, and most advanced 91 | features will probably not work. 92 | 93 | - Variadic arguments of file and path type 94 | - Promts (probably never will) 95 | - Custom ParamTypes (depending on implementation) 96 | 97 | TODO 98 | ==== 99 | 100 | - Abort started/running processes. 101 | - Browser history 102 | 103 | 104 | Included 3:rd party libraries 105 | ============================= 106 | `SplitJs`_ - Copyright (c) 2020 Nathan Cahill (MIT license) 107 | 108 | .. _SplitJs: https://github.com/nathancahill/split/blob/master/packages/splitjs/LICENSE.txt 109 | -------------------------------------------------------------------------------- /click_web/__init__.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | from pathlib import Path 3 | 4 | import click 5 | import jinja2 6 | from flask import Blueprint, Flask 7 | 8 | import click_web.resources.cmd_exec 9 | import click_web.resources.cmd_form 10 | import click_web.resources.index 11 | 12 | jinja_env = jinja2.Environment(extensions=['jinja2.ext.do']) 13 | 14 | 'The full path to the click script file to execute.' 15 | script_file = None 16 | 'The click root command to serve' 17 | click_root_cmd = None 18 | 19 | 20 | def _get_output_folder(): 21 | _output_folder = (Path(tempfile.gettempdir()) / 'click-web') 22 | if not _output_folder.exists(): 23 | _output_folder.mkdir() 24 | return _output_folder 25 | 26 | 27 | 'Where to place result files for download' 28 | OUTPUT_FOLDER = str(_get_output_folder()) 29 | 30 | _flask_app = None 31 | logger = None 32 | 33 | 34 | def create_click_web_app(module, command: click.BaseCommand, root='/'): 35 | """ 36 | Create a Flask app that wraps a click command. (Call once) 37 | 38 | :param module: the module that contains the click command, needed to get the path to the script. 39 | :param command: The actual click root command, needed to be able to read the command tree and arguments 40 | in order to generate the index page and the html forms 41 | :param root: the root url path to server click-web under. 42 | usage: 43 | 44 | from click_web import create_click_web_app 45 | 46 | import a_click_script 47 | 48 | app = create_click_web_app(a_click_script, a_click_script.a_group_or_command) 49 | 50 | """ 51 | global _flask_app, logger 52 | assert _flask_app is None, "Flask App already created." 53 | 54 | _register(module, command) 55 | 56 | _flask_app = Flask(__name__, static_url_path=root.rstrip('/') + '/static') 57 | 58 | _flask_app.config['APPLICATION_ROOT'] = root 59 | root = root.rstrip('/') 60 | 61 | # add the "do" extension needed by our jinja templates 62 | _flask_app.jinja_env.add_extension('jinja2.ext.do') 63 | 64 | _flask_app.add_url_rule(root + '/', 'index', click_web.resources.index.index) 65 | _flask_app.add_url_rule(root + '/', 'command', click_web.resources.cmd_form.get_form_for) 66 | 67 | executor = click_web.resources.cmd_exec.Executor() 68 | _flask_app.add_url_rule(root + '/', 'command_execute', executor.exec, 69 | methods=['POST']) 70 | 71 | _flask_app.logger.info(f'OUTPUT_FOLDER: {OUTPUT_FOLDER}') 72 | results_blueprint = Blueprint('results', __name__, static_url_path=root + '/static/results', 73 | static_folder=OUTPUT_FOLDER) 74 | _flask_app.register_blueprint(results_blueprint) 75 | 76 | logger = _flask_app.logger 77 | 78 | return _flask_app 79 | 80 | 81 | def _register(module, command: click.BaseCommand): 82 | """ 83 | 84 | :param module: the module that contains the command, needed to get the path to the script. 85 | :param command: The actual click root command, needed to be able to read the command tree and arguments 86 | in order to generate the index page and the html forms 87 | """ 88 | global click_root_cmd, script_file 89 | script_file = str(Path(module.__file__).absolute()) 90 | click_root_cmd = command 91 | -------------------------------------------------------------------------------- /tests/fixtures/script/a_script.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | 4 | @click.group() 5 | @click.option("--debug/--no-debug", help='Global debug flag') 6 | def cli(debug): 7 | 'the root command' 8 | pass 9 | 10 | 11 | @cli.command() 12 | def simple_no_params_command(): 13 | 'Help text' 14 | click.echo("Simpel noparams command called") 15 | 16 | 17 | @cli.command() 18 | @click.option("--unicode-msg", type=click.Choice(['Åäö']), default='Åäö', required=True, 19 | help='Message with unicide chars to print.') 20 | def unicode_test(unicode_msg): 21 | "Just print unicode message" 22 | click.echo(f"This {unicode_msg} should be Åäö") 23 | 24 | 25 | @cli.group() 26 | def sub_group(): 27 | 'a sub group' 28 | pass 29 | 30 | 31 | @sub_group.command() 32 | def a_sub_group_command(): 33 | 'Help for sub_group.sub_group_command ' 34 | click.echo("Sub group command called") 35 | 36 | 37 | @cli.command() 38 | @click.option("--an-option", "the_option_value", type=str, default="option_value", help='help for an option') 39 | @click.argument("an-argument", default=10, type=int) 40 | def command_with_option_and_argument(an_argument, the_option_value): 41 | 'Help text' 42 | click.echo(f"Ran command with option: {the_option_value} argument {an_argument}") 43 | 44 | 45 | @cli.command() 46 | @click.option("--an-option", type=str, nargs=2, help='help for an option') 47 | def command_with_nargs_option(an_option): 48 | 'Help text' 49 | click.echo(f"Ran command with option: {an_option}") 50 | 51 | 52 | @cli.command() 53 | @click.option("--flag", is_flag=True, default=False, help='help for flag') 54 | def command_with_flag_without_off_option(flag): 55 | 'Help text' 56 | click.echo(f"Ran command with flag {flag}") 57 | 58 | 59 | @cli.command() 60 | @click.option("--flag/--no-flag", default=True, help='help for flag') 61 | def command_with_default_on_flag_option(flag): 62 | 'Help text' 63 | click.echo(f"Ran command with flag {flag}") 64 | 65 | 66 | @cli.command() 67 | @click.argument('folder', type=click.Path(exists=True)) 68 | def command_with_input_folder(folder): 69 | click.echo(click.format_filename(folder)) 70 | 71 | 72 | @cli.command() 73 | @click.argument('folder', type=click.Path()) 74 | def command_with_output_folder(folder): 75 | click.echo(click.format_filename(folder)) 76 | 77 | 78 | @cli.command() 79 | @click.option('--outfile', type=click.File('w')) 80 | def command_with_output_file(outfile): 81 | click.echo(outfile) 82 | if outfile: 83 | outfile.write("test") 84 | 85 | 86 | class ACustomParamType(click.ParamType): 87 | """Just a stupid custom param type""" 88 | name = 'my_custom_type' 89 | 90 | def convert(self, value, param, ctx): 91 | if value.lower() == 'spamspam': 92 | return value 93 | else: 94 | self.fail(f'{value} is not valid', param, ctx) 95 | 96 | 97 | @cli.command() 98 | @click.argument('custom', type=ACustomParamType()) 99 | def command_with_custom_type(custom): 100 | "Argument must be set to 'spamspam' to be valid" 101 | click.echo(f'{custom} is valid.') 102 | 103 | 104 | @cli.command() 105 | @click.argument("users", nargs=-1) 106 | def command_with_variadic_args(users): 107 | "Command with variadic args" 108 | for i, user in enumerate(users): 109 | click.echo(f"Hi {user}, you are number {i + 1}") 110 | 111 | 112 | if __name__ == '__main__': 113 | cli() 114 | -------------------------------------------------------------------------------- /click_web/resources/cmd_form.py: -------------------------------------------------------------------------------- 1 | from html import escape 2 | from typing import List, Tuple 3 | 4 | import click 5 | from flask import abort, render_template 6 | 7 | import click_web 8 | from click_web.exceptions import CommandNotFound 9 | from click_web.resources.input_fields import get_input_field 10 | 11 | 12 | def get_form_for(command_path: str): 13 | try: 14 | ctx_and_commands = _get_commands_by_path(command_path) 15 | except CommandNotFound as err: 16 | return abort(404, str(err)) 17 | 18 | levels = _generate_form_data(ctx_and_commands) 19 | return render_template('command_form.html.j2', 20 | levels=levels, 21 | command=levels[-1]['command'], 22 | command_path=command_path) 23 | 24 | 25 | def _get_commands_by_path(command_path: str) -> List[Tuple[click.Context, click.Command]]: 26 | """ 27 | Take a (slash separated) string and generate (context, command) for each level. 28 | :param command_path: "some_group/a_command" 29 | :return: Return a list from root to leaf commands. each element is (Click.Context, Click.Command) 30 | """ 31 | command_path_items = command_path.split('/') 32 | command_name, *command_path_items = command_path_items 33 | command = click_web.click_root_cmd 34 | if command.name != command_name: 35 | raise CommandNotFound('Failed to find root command {}. There is a root command named:{}' 36 | .format(command_name, command.name)) 37 | result = [] 38 | with click.Context(command, info_name=command, parent=None) as ctx: 39 | result.append((ctx, command)) 40 | # dig down the path parts to find the leaf command 41 | parent_command = command 42 | for command_name in command_path_items: 43 | command = parent_command.get_command(ctx, command_name) 44 | if command: 45 | # create sub context for command 46 | ctx = click.Context(command, info_name=command, parent=ctx) 47 | parent_command = command 48 | else: 49 | raise CommandNotFound('Failed to find command for path "{}". Command "{}" not found. Must be one of {}' 50 | .format(command_path, command_name, parent_command.list_commands(ctx))) 51 | result.append((ctx, command)) 52 | return result 53 | 54 | 55 | def _generate_form_data(ctx_and_commands: List[Tuple[click.Context, click.Command]]): 56 | """ 57 | Construct a list of contexts and commands generate a python data structure for rendering jinja form 58 | :return: a list of dicts 59 | """ 60 | levels = [] 61 | for command_index, (ctx, command) in enumerate(ctx_and_commands): 62 | # force help option off, no need in web. 63 | command.add_help_option = False 64 | command.html_help = _process_help(command.help) 65 | 66 | input_fields = [get_input_field(ctx, param, command_index, param_index) 67 | for param_index, param in enumerate(command.get_params(ctx))] 68 | levels.append({'command': command, 'fields': input_fields}) 69 | 70 | return levels 71 | 72 | 73 | def _process_help(help_text): 74 | """ 75 | Convert click command help into html to be presented to browser. 76 | Respects the '\b' char used by click to mark pre-formatted blocks. 77 | Also escapes html reserved characters in the help text. 78 | 79 | :param help_text: str 80 | :return: A html formatted help string. 81 | """ 82 | help_ = [] 83 | in_pre = False 84 | html_help = '' 85 | if not help_text: 86 | return html_help 87 | 88 | line_iter = iter(help_text.splitlines()) 89 | while True: 90 | try: 91 | line = next(line_iter) 92 | if in_pre and not line.strip(): 93 | # end of code block 94 | in_pre = False 95 | html_help += '\n'.join(help_) 96 | help_ = [''] 97 | continue 98 | elif line.strip() == '\b': 99 | # start of code block 100 | in_pre = True 101 | html_help += '
\n'.join(help_) 102 | help_ = ['
']
103 |                 continue
104 |             help_.append(escape(line))
105 |         except StopIteration:
106 |             break
107 | 
108 |     html_help += '\n'.join(help_) if in_pre else '
\n'.join(help_) 109 | return html_help 110 | -------------------------------------------------------------------------------- /click_web/static/post_and_read.js: -------------------------------------------------------------------------------- 1 | let REQUEST_RUNNING = false; 2 | 3 | function postAndRead(commandUrl) { 4 | if (REQUEST_RUNNING) { 5 | return false; 6 | } 7 | 8 | input_form = document.getElementById("inputform"); 9 | let submit_btn = document.getElementById("submit_btn"); 10 | 11 | try { 12 | REQUEST_RUNNING = true; 13 | submit_btn.disabled = true; 14 | let runner = new ExecuteAndProcessOutput(input_form, commandUrl); 15 | runner.run(); 16 | } catch (e) { 17 | console.error(e); 18 | 19 | } finally { 20 | // if we executed anything never post form 21 | // as we do not know if form already was submitted. 22 | return false; 23 | 24 | } 25 | } 26 | 27 | class ExecuteAndProcessOutput { 28 | constructor(form, commandPath) { 29 | this.form = form; 30 | this.commandUrl = commandPath; 31 | this.decoder = new TextDecoder('utf-8'); 32 | this.output_header_div = document.getElementById("output-header") 33 | this.output_wrapper_div = document.getElementById("output-wrapper") 34 | this.output_div = document.getElementById("output") 35 | this.output_footer_div = document.getElementById("output-footer") 36 | // clear old content 37 | this.output_header_div.innerHTML = ''; 38 | this.output_div.innerHTML = ''; 39 | this.output_footer_div.innerHTML = ''; 40 | // show script output 41 | this.output_header_div.hidden = false; 42 | this.output_wrapper_div.hidden = false; 43 | this.output_footer_div.hidden = false; 44 | } 45 | 46 | run() { 47 | let submit_btn = document.getElementById("submit_btn"); 48 | this.post(this.commandUrl) 49 | .then(response => { 50 | this.form.disabled = true; 51 | let reader = response.body.getReader(); 52 | return this.processStreamReader(reader); 53 | }) 54 | .then(_ => { 55 | REQUEST_RUNNING = false 56 | submit_btn.disabled = false; 57 | }) 58 | .catch(error => { 59 | console.error(error); 60 | REQUEST_RUNNING = false; 61 | submit_btn.disabled = false; 62 | 63 | } 64 | ); 65 | } 66 | 67 | post() { 68 | console.log("Posting to " + this.commandUrl); 69 | return fetch(this.commandUrl, { 70 | method: "POST", 71 | body: new FormData(this.form), 72 | // for fetch streaming only accept plain text, we wont handle html 73 | headers: {Accept: 'text/plain'} 74 | }); 75 | } 76 | 77 | async processStreamReader(reader) { 78 | while (true) { 79 | const result = await reader.read(); 80 | let chunk = this.decoder.decode(result.value); 81 | console.log(chunk); 82 | let insert_func = this.output_div.insertAdjacentText; 83 | let elem = this.output_div; 84 | 85 | // Split the read chunk into sections if needed. 86 | // Below implementation is not perfect as it expects the CLICK_WEB section markers to be 87 | // complete and not in separate chunks. However it seems to work fine 88 | // as long as the generating server yields the CLICK_WEB section in one string as they should be 89 | // quite small. 90 | if (chunk.includes(')/); 94 | for (let part of parts) { 95 | [elem, insert_func] = this.getInsertFunc(part, elem, insert_func); 96 | if (part.startsWith('' 140 | yield '
Executing: {}
'.format('/'.join(str(c) for c in commands)) 141 | yield '' 142 | 143 | # important yield this block as one string so it pushed to client in one go. 144 | # so the whole block can be treated as html. 145 | html_str = '\n'.join(generate()) 146 | return html_str 147 | 148 | def _create_result_footer(self): 149 | """ 150 | Generate a footer. 151 | Note: 152 | here we always allow to generate HTML as long as we have it between CLICK-WEB comments. 153 | This way the JS frontend can insert it in the correct place in the DOM. 154 | """ 155 | # important yield this block as one string so it pushed to client in one go. 156 | # This is so the whole block can be treated as html if JS frontend. 157 | to_download = self._command_line.get_download_field_infos() 158 | lines = [''] 159 | if to_download: 160 | lines.append('Result files:
') 161 | for fi in to_download: 162 | lines.append('
    ') 163 | lines.append(f'
  • {_get_download_link(fi)}
    ') 164 | lines.append('
') 165 | 166 | if self.returncode == 0: 167 | lines.append('
Done
') 168 | else: 169 | lines.append(f'
' 170 | f'Script exited with error code: {self.returncode}
') 171 | 172 | lines.append('') 173 | html_str = '\n'.join(lines) 174 | yield html_str 175 | 176 | 177 | def _get_download_link(field_info): 178 | """Hack as url_for need request context""" 179 | 180 | rel_file_path = Path(field_info.file_path).relative_to(click_web.OUTPUT_FOLDER) 181 | uri = f'/static/results/{rel_file_path.as_posix()}' 182 | return f'{field_info.link_name}' 183 | 184 | 185 | class CommandLineRaw: 186 | def __init__(self, script_file_path: str, command): 187 | self._parts = [] 188 | self.append(_get_python_interpreter()) 189 | self.append(script_file_path) 190 | for arg in shlex.split(command): 191 | self.append(arg) 192 | 193 | def append(self, part: str, secret: bool = False): 194 | self._parts.append(part) 195 | 196 | def get_commandline(self, obfuscate: bool = False) -> List[str]: 197 | """ 198 | Return command line as a list of strings. 199 | obfuscate - not supported for this implementation 200 | """ 201 | return self._parts 202 | 203 | def get_download_field_infos(self): 204 | return [] 205 | 206 | def after_script_executed(self): 207 | pass 208 | 209 | 210 | class CommandLineForm: 211 | def __init__(self, script_file_path: str, commands: List[str]): 212 | self._parts: List[CmdPart] = list() 213 | self.append(_get_python_interpreter()) 214 | self.append(script_file_path) 215 | 216 | self.command_line_bulder = FormToCommandLineBuilder(self) 217 | 218 | # root command_index should not add a command 219 | self.command_line_bulder.add_command_args(0) 220 | for i, command in enumerate(commands): 221 | self.append(command) 222 | self.command_line_bulder.add_command_args(i + 1) 223 | 224 | def append(self, part: str, secret: bool = False): 225 | self._parts.append(CmdPart(part, secret)) 226 | 227 | def get_commandline(self, obfuscate: bool = False) -> List[str]: 228 | """ 229 | Return command line as a list of strings. 230 | obfuscate - if True secret parts like passwords are replaced with *****. Use for logging etc. 231 | """ 232 | return ['******' if cmd_part.secret and obfuscate else str(cmd_part) 233 | for cmd_part in self._parts] 234 | 235 | def get_download_field_infos(self): 236 | return [fi for fi in self.command_line_bulder.field_infos 237 | if fi.generate_download_link and fi.link_name] 238 | 239 | def after_script_executed(self): 240 | """Call this after the command has executed""" 241 | for fi in self.command_line_bulder.field_infos: 242 | fi.after_script_executed() 243 | 244 | 245 | def _get_python_interpreter(): 246 | if sys.executable.endswith("uwsgi"): 247 | import uwsgi 248 | python_interpreter = str((Path(uwsgi.opt.get("virtualenv").decode()) / "bin" / "python").absolute()) 249 | else: 250 | # run with same python executable we are running with. 251 | python_interpreter = sys.executable 252 | return python_interpreter 253 | 254 | 255 | class CmdPart: 256 | def __init__(self, part: str, secret=False): 257 | self.part = part 258 | self.secret = secret 259 | 260 | def __str__(self): 261 | return self.part 262 | 263 | 264 | class FormToCommandLineBuilder: 265 | 266 | def __init__(self, command_line: CommandLineForm): 267 | self.command_line = command_line 268 | field_infos = [FieldInfo.factory(key) for key in list(request.form.keys()) + list(request.files.keys())] 269 | # important to sort them so they will be in expected order on command line 270 | self.field_infos = list(sorted(field_infos)) 271 | 272 | def add_command_args(self, command_index: int): 273 | """ 274 | Convert the post request into a list of command line arguments 275 | 276 | :param command_index: (int) the index for the command to get arguments for. 277 | """ 278 | 279 | # only include relevant fields for this command index 280 | commands_field_infos = [fi for fi in self.field_infos if fi.param.command_index == command_index] 281 | commands_field_infos = sorted(commands_field_infos) 282 | 283 | for fi in commands_field_infos: 284 | 285 | # must be called mostly for saving and preparing file output. 286 | fi.before_script_execute() 287 | 288 | if self._is_option(fi.cmd_opt): 289 | self._process_option(fi) 290 | else: 291 | # argument(s) 292 | if isinstance(fi, FieldFileInfo): 293 | # it's a file, append the written temp file path 294 | # TODO: does file upload support multiple keys? In that case support it. 295 | self.command_line.append(fi.file_path) 296 | else: 297 | arg_values = request.form.getlist(fi.key) 298 | has_values = bool(''.join(arg_values)) 299 | # If arg value is empty the field was not filled, and thus optional argument 300 | if has_values: 301 | if fi.param.nargs == -1: 302 | # Variadic argument, in html form each argument is a separate line in a textarea. 303 | # treat each line we get from text area as a separate argument. 304 | for value in arg_values: 305 | values = value.splitlines() 306 | for val in values: 307 | self.command_line.append(val, secret=fi.param.form_type == 'password') 308 | else: 309 | for val in arg_values: 310 | self.command_line.append(val, secret=fi.param.form_type == 'password') 311 | 312 | @staticmethod 313 | def _is_option(cmd_option): 314 | return isinstance(cmd_option, str) and \ 315 | (cmd_option.startswith('--') or cmd_option.startswith('-')) 316 | 317 | def _process_option(self, field_info): 318 | vals = request.form.getlist(field_info.key) 319 | if field_info.is_file: 320 | if field_info.link_name: 321 | # it's a file, append the file path 322 | self.command_line.append(field_info.cmd_opt) 323 | self.command_line.append(field_info.file_path) 324 | elif field_info.param.param_type == 'flag': 325 | # To work with flag that is default True a hidden field with same name is also sent by form. 326 | # This is to detect if checkbox was not checked as then we will get the field anyway with the "off flag" 327 | # as value. 328 | if len(vals) == 1: 329 | off_flag = vals[0] 330 | flag_on_cmd_line = off_flag 331 | else: 332 | # we got both off and on flags, checkbox is checked. 333 | on_flag = vals[1] 334 | flag_on_cmd_line = on_flag 335 | 336 | self.command_line.append(flag_on_cmd_line) 337 | elif ''.join(vals): 338 | # opt with value, if option was given multiple times get the values for each. 339 | # flag options should always be set if we get them 340 | # for normal options they must have a non empty value 341 | self.command_line.append(field_info.cmd_opt) 342 | for val in vals: 343 | if val: 344 | self.command_line.append(val, secret=field_info.param.form_type == 'password') 345 | else: 346 | # option with empty values, should not be added to command line. 347 | pass 348 | 349 | 350 | class FieldInfo: 351 | """ 352 | Extract information from the encoded form input field name 353 | the parts: 354 | [command_index].[opt_or_arg_index].[click_type].[html_input_type].[opt_or_arg_name] 355 | e.g. 356 | "0.0.option.text.text.--an-option" 357 | "0.1.argument.file[rb].text.an-argument" 358 | """ 359 | 360 | @staticmethod 361 | def factory(key): 362 | field_id = FieldId.from_string(key) 363 | is_file = field_id.click_type.startswith('file') 364 | is_path = field_id.click_type.startswith('path') 365 | is_uploaded = key in request.files 366 | if is_file: 367 | if is_uploaded: 368 | field_info = FieldFileInfo(field_id) 369 | else: 370 | field_info = FieldOutFileInfo(field_id) 371 | elif is_path: 372 | if is_uploaded: 373 | field_info = FieldPathInfo(field_id) 374 | else: 375 | field_info = FieldPathOutInfo(field_id) 376 | else: 377 | field_info = FieldInfo(field_id) 378 | return field_info 379 | 380 | def __init__(self, param: FieldId): 381 | self.param = param 382 | self.key = param.key 383 | 384 | 'Type of option (file, text)' 385 | self.is_file = self.param.click_type.startswith('file') 386 | 387 | 'The actual command line option (--debug)' 388 | self.cmd_opt = param.name 389 | 390 | self.generate_download_link = False 391 | 392 | def before_script_execute(self): 393 | pass 394 | 395 | def after_script_executed(self): 396 | pass 397 | 398 | def __str__(self): 399 | return str(self.param) 400 | 401 | def __lt__(self, other): 402 | # Make class sortable 403 | return (self.param.command_index, self.param.param_index) < \ 404 | (other.param.command_index, other.param.param_index) 405 | 406 | def __eq__(self, other): 407 | return self.key == other.key 408 | 409 | 410 | class FieldFileInfo(FieldInfo): 411 | """ 412 | Use for processing input fields of file type. 413 | Saves the posted data to a temp file. 414 | """ 415 | 'temp dir is on class in order to be uniqe for each request' 416 | _temp_dir = None 417 | 418 | def __init__(self, fimeta): 419 | super().__init__(fimeta) 420 | # Extract the file mode that is in the type e.g file[rw] 421 | self.mode = self.param.click_type.split('[')[1][:-1] 422 | self.generate_download_link = True if 'w' in self.mode else False 423 | self.link_name = f'{self.cmd_opt}.out' 424 | self.file_path = None 425 | 426 | logger.info(f'File mode for {self.key} is {self.mode}') 427 | 428 | def before_script_execute(self): 429 | self.save() 430 | 431 | @classmethod 432 | def temp_dir(cls): 433 | if not cls._temp_dir: 434 | cls._temp_dir = tempfile.mkdtemp(dir=click_web.OUTPUT_FOLDER) 435 | logger.info(f'Temp dir: {cls._temp_dir}') 436 | return cls._temp_dir 437 | 438 | def save(self): 439 | logger.info('Saving...') 440 | 441 | logger.info('field value is a file! %s', self.key) 442 | file = request.files[self.key] 443 | # if user does not select file, browser also 444 | # submit a empty part without filename 445 | if file.filename == '': 446 | raise ValueError('No selected file') 447 | elif file and file.filename: 448 | filename = secure_filename(file.filename) 449 | name, suffix = os.path.splitext(filename) 450 | 451 | fd, filename = tempfile.mkstemp(dir=self.temp_dir(), prefix=name, suffix=suffix) 452 | self.file_path = filename 453 | logger.info(f'Saving {self.key} to {filename}') 454 | file.save(filename) 455 | 456 | def __str__(self): 457 | res = [super().__str__(), f'file_path: {self.file_path}'] 458 | return ', '.join(res) 459 | 460 | 461 | class FieldOutFileInfo(FieldFileInfo): 462 | """ 463 | Used when file option is just for output and form posted it as hidden or text field. 464 | Just create a empty temp file to give it's path to command. 465 | """ 466 | 467 | def __init__(self, fimeta): 468 | super().__init__(fimeta) 469 | if self.param.form_type == 'text': 470 | self.link_name = request.form[self.key] 471 | # set the postfix to name provided from form 472 | # this way it will at least have the same extension when downloaded 473 | self.file_suffix = request.form[self.key] 474 | else: 475 | # hidden no preferred file name can be provided by user 476 | self.file_suffix = '.out' 477 | 478 | def save(self): 479 | name = secure_filename(self.key) 480 | 481 | fd, filename = tempfile.mkstemp(dir=self.temp_dir(), prefix=name, suffix=self.file_suffix) 482 | logger.info(f'Creating empty file for {self.key} as {filename}') 483 | self.file_path = filename 484 | 485 | 486 | class FieldPathInfo(FieldFileInfo): 487 | """ 488 | Use for processing input fields of path type. 489 | Extracts the posted data to a temp folder. 490 | When script finished zip that folder and provide download link to zip file. 491 | """ 492 | 493 | def save(self): 494 | super().save() 495 | zip_extract_dir = tempfile.mkdtemp(dir=self.temp_dir()) 496 | 497 | logger.info(f'Extracting: {self.file_path} to {zip_extract_dir}') 498 | shutil.unpack_archive(self.file_path, zip_extract_dir, 'zip') 499 | self.file_path = zip_extract_dir 500 | 501 | def after_script_executed(self): 502 | super().after_script_executed() 503 | self.file_path = zip_folder(self.file_path, self.temp_dir(), out_prefix=self.key) 504 | self.generate_download_link = True 505 | 506 | 507 | class FieldPathOutInfo(FieldOutFileInfo): 508 | """ 509 | Use for processing output fields of path type. 510 | Create a folder and use as path to script. 511 | When script finished zip that folder and provide download link to zip file. 512 | """ 513 | 514 | def save(self): 515 | super().save() 516 | self.file_path = tempfile.mkdtemp(dir=self.temp_dir()) 517 | 518 | def after_script_executed(self): 519 | super().after_script_executed() 520 | self.file_path = zip_folder(self.file_path, self.temp_dir(), out_prefix=self.key) 521 | self.generate_download_link = True 522 | 523 | 524 | def zip_folder(folder_path, out_folder, out_prefix): 525 | fd, out_base_name = tempfile.mkstemp(dir=out_folder, prefix=out_prefix) 526 | logger.info(f'Zipping {folder_path}') 527 | zip_file_path = shutil.make_archive(out_base_name, 'zip', folder_path) 528 | logger.info(f'Zip file created {zip_file_path}') 529 | return zip_file_path 530 | -------------------------------------------------------------------------------- /click_web/static/split.js: -------------------------------------------------------------------------------- 1 | /*! Split.js - v1.6.0 */ 2 | 3 | (function (global, factory) { 4 | typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : 5 | typeof define === 'function' && define.amd ? define(factory) : 6 | (global = global || self, global.Split = factory()); 7 | }(this, (function () { 'use strict'; 8 | 9 | // The programming goals of Split.js are to deliver readable, understandable and 10 | // maintainable code, while at the same time manually optimizing for tiny minified file size, 11 | // browser compatibility without additional requirements 12 | // and very few assumptions about the user's page layout. 13 | var global = typeof window !== 'undefined' ? window : null; 14 | var ssr = global === null; 15 | var document = !ssr ? global.document : undefined; 16 | 17 | // Save a couple long function names that are used frequently. 18 | // This optimization saves around 400 bytes. 19 | var addEventListener = 'addEventListener'; 20 | var removeEventListener = 'removeEventListener'; 21 | var getBoundingClientRect = 'getBoundingClientRect'; 22 | var gutterStartDragging = '_a'; 23 | var aGutterSize = '_b'; 24 | var bGutterSize = '_c'; 25 | var HORIZONTAL = 'horizontal'; 26 | var NOOP = function () { return false; }; 27 | 28 | // Helper function determines which prefixes of CSS calc we need. 29 | // We only need to do this once on startup, when this anonymous function is called. 30 | // 31 | // Tests -webkit, -moz and -o prefixes. Modified from StackOverflow: 32 | // http://stackoverflow.com/questions/16625140/js-feature-detection-to-detect-the-usage-of-webkit-calc-over-calc/16625167#16625167 33 | var calc = ssr 34 | ? 'calc' 35 | : ((['', '-webkit-', '-moz-', '-o-'] 36 | .filter(function (prefix) { 37 | var el = document.createElement('div'); 38 | el.style.cssText = "width:" + prefix + "calc(9px)"; 39 | 40 | return !!el.style.length 41 | }) 42 | .shift()) + "calc"); 43 | 44 | // Helper function checks if its argument is a string-like type 45 | var isString = function (v) { return typeof v === 'string' || v instanceof String; }; 46 | 47 | // Helper function allows elements and string selectors to be used 48 | // interchangeably. In either case an element is returned. This allows us to 49 | // do `Split([elem1, elem2])` as well as `Split(['#id1', '#id2'])`. 50 | var elementOrSelector = function (el) { 51 | if (isString(el)) { 52 | var ele = document.querySelector(el); 53 | if (!ele) { 54 | throw new Error(("Selector " + el + " did not match a DOM element")) 55 | } 56 | return ele 57 | } 58 | 59 | return el 60 | }; 61 | 62 | // Helper function gets a property from the properties object, with a default fallback 63 | var getOption = function (options, propName, def) { 64 | var value = options[propName]; 65 | if (value !== undefined) { 66 | return value 67 | } 68 | return def 69 | }; 70 | 71 | var getGutterSize = function (gutterSize, isFirst, isLast, gutterAlign) { 72 | if (isFirst) { 73 | if (gutterAlign === 'end') { 74 | return 0 75 | } 76 | if (gutterAlign === 'center') { 77 | return gutterSize / 2 78 | } 79 | } else if (isLast) { 80 | if (gutterAlign === 'start') { 81 | return 0 82 | } 83 | if (gutterAlign === 'center') { 84 | return gutterSize / 2 85 | } 86 | } 87 | 88 | return gutterSize 89 | }; 90 | 91 | // Default options 92 | var defaultGutterFn = function (i, gutterDirection) { 93 | var gut = document.createElement('div'); 94 | gut.className = "gutter gutter-" + gutterDirection; 95 | return gut 96 | }; 97 | 98 | var defaultElementStyleFn = function (dim, size, gutSize) { 99 | var style = {}; 100 | 101 | if (!isString(size)) { 102 | style[dim] = calc + "(" + size + "% - " + gutSize + "px)"; 103 | } else { 104 | style[dim] = size; 105 | } 106 | 107 | return style 108 | }; 109 | 110 | var defaultGutterStyleFn = function (dim, gutSize) { 111 | var obj; 112 | 113 | return (( obj = {}, obj[dim] = (gutSize + "px"), obj )); 114 | }; 115 | 116 | // The main function to initialize a split. Split.js thinks about each pair 117 | // of elements as an independant pair. Dragging the gutter between two elements 118 | // only changes the dimensions of elements in that pair. This is key to understanding 119 | // how the following functions operate, since each function is bound to a pair. 120 | // 121 | // A pair object is shaped like this: 122 | // 123 | // { 124 | // a: DOM element, 125 | // b: DOM element, 126 | // aMin: Number, 127 | // bMin: Number, 128 | // dragging: Boolean, 129 | // parent: DOM element, 130 | // direction: 'horizontal' | 'vertical' 131 | // } 132 | // 133 | // The basic sequence: 134 | // 135 | // 1. Set defaults to something sane. `options` doesn't have to be passed at all. 136 | // 2. Initialize a bunch of strings based on the direction we're splitting. 137 | // A lot of the behavior in the rest of the library is paramatized down to 138 | // rely on CSS strings and classes. 139 | // 3. Define the dragging helper functions, and a few helpers to go with them. 140 | // 4. Loop through the elements while pairing them off. Every pair gets an 141 | // `pair` object and a gutter. 142 | // 5. Actually size the pair elements, insert gutters and attach event listeners. 143 | var Split = function (idsOption, options) { 144 | if ( options === void 0 ) options = {}; 145 | 146 | if (ssr) { return {} } 147 | 148 | var ids = idsOption; 149 | var dimension; 150 | var clientAxis; 151 | var position; 152 | var positionEnd; 153 | var clientSize; 154 | var elements; 155 | 156 | // Allow HTMLCollection to be used as an argument when supported 157 | if (Array.from) { 158 | ids = Array.from(ids); 159 | } 160 | 161 | // All DOM elements in the split should have a common parent. We can grab 162 | // the first elements parent and hope users read the docs because the 163 | // behavior will be whacky otherwise. 164 | var firstElement = elementOrSelector(ids[0]); 165 | var parent = firstElement.parentNode; 166 | var parentStyle = getComputedStyle ? getComputedStyle(parent) : null; 167 | var parentFlexDirection = parentStyle ? parentStyle.flexDirection : null; 168 | 169 | // Set default options.sizes to equal percentages of the parent element. 170 | var sizes = getOption(options, 'sizes') || ids.map(function () { return 100 / ids.length; }); 171 | 172 | // Standardize minSize to an array if it isn't already. This allows minSize 173 | // to be passed as a number. 174 | var minSize = getOption(options, 'minSize', 100); 175 | var minSizes = Array.isArray(minSize) ? minSize : ids.map(function () { return minSize; }); 176 | 177 | // Get other options 178 | var expandToMin = getOption(options, 'expandToMin', false); 179 | var gutterSize = getOption(options, 'gutterSize', 10); 180 | var gutterAlign = getOption(options, 'gutterAlign', 'center'); 181 | var snapOffset = getOption(options, 'snapOffset', 30); 182 | var dragInterval = getOption(options, 'dragInterval', 1); 183 | var direction = getOption(options, 'direction', HORIZONTAL); 184 | var cursor = getOption( 185 | options, 186 | 'cursor', 187 | direction === HORIZONTAL ? 'col-resize' : 'row-resize' 188 | ); 189 | var gutter = getOption(options, 'gutter', defaultGutterFn); 190 | var elementStyle = getOption( 191 | options, 192 | 'elementStyle', 193 | defaultElementStyleFn 194 | ); 195 | var gutterStyle = getOption(options, 'gutterStyle', defaultGutterStyleFn); 196 | 197 | // 2. Initialize a bunch of strings based on the direction we're splitting. 198 | // A lot of the behavior in the rest of the library is paramatized down to 199 | // rely on CSS strings and classes. 200 | if (direction === HORIZONTAL) { 201 | dimension = 'width'; 202 | clientAxis = 'clientX'; 203 | position = 'left'; 204 | positionEnd = 'right'; 205 | clientSize = 'clientWidth'; 206 | } else if (direction === 'vertical') { 207 | dimension = 'height'; 208 | clientAxis = 'clientY'; 209 | position = 'top'; 210 | positionEnd = 'bottom'; 211 | clientSize = 'clientHeight'; 212 | } 213 | 214 | // 3. Define the dragging helper functions, and a few helpers to go with them. 215 | // Each helper is bound to a pair object that contains its metadata. This 216 | // also makes it easy to store references to listeners that that will be 217 | // added and removed. 218 | // 219 | // Even though there are no other functions contained in them, aliasing 220 | // this to self saves 50 bytes or so since it's used so frequently. 221 | // 222 | // The pair object saves metadata like dragging state, position and 223 | // event listener references. 224 | 225 | function setElementSize(el, size, gutSize, i) { 226 | // Split.js allows setting sizes via numbers (ideally), or if you must, 227 | // by string, like '300px'. This is less than ideal, because it breaks 228 | // the fluid layout that `calc(% - px)` provides. You're on your own if you do that, 229 | // make sure you calculate the gutter size by hand. 230 | var style = elementStyle(dimension, size, gutSize, i); 231 | 232 | Object.keys(style).forEach(function (prop) { 233 | // eslint-disable-next-line no-param-reassign 234 | el.style[prop] = style[prop]; 235 | }); 236 | } 237 | 238 | function setGutterSize(gutterElement, gutSize, i) { 239 | var style = gutterStyle(dimension, gutSize, i); 240 | 241 | Object.keys(style).forEach(function (prop) { 242 | // eslint-disable-next-line no-param-reassign 243 | gutterElement.style[prop] = style[prop]; 244 | }); 245 | } 246 | 247 | function getSizes() { 248 | return elements.map(function (element) { return element.size; }) 249 | } 250 | 251 | // Supports touch events, but not multitouch, so only the first 252 | // finger `touches[0]` is counted. 253 | function getMousePosition(e) { 254 | if ('touches' in e) { return e.touches[0][clientAxis] } 255 | return e[clientAxis] 256 | } 257 | 258 | // Actually adjust the size of elements `a` and `b` to `offset` while dragging. 259 | // calc is used to allow calc(percentage + gutterpx) on the whole split instance, 260 | // which allows the viewport to be resized without additional logic. 261 | // Element a's size is the same as offset. b's size is total size - a size. 262 | // Both sizes are calculated from the initial parent percentage, 263 | // then the gutter size is subtracted. 264 | function adjust(offset) { 265 | var a = elements[this.a]; 266 | var b = elements[this.b]; 267 | var percentage = a.size + b.size; 268 | 269 | a.size = (offset / this.size) * percentage; 270 | b.size = percentage - (offset / this.size) * percentage; 271 | 272 | setElementSize(a.element, a.size, this[aGutterSize], a.i); 273 | setElementSize(b.element, b.size, this[bGutterSize], b.i); 274 | } 275 | 276 | // drag, where all the magic happens. The logic is really quite simple: 277 | // 278 | // 1. Ignore if the pair is not dragging. 279 | // 2. Get the offset of the event. 280 | // 3. Snap offset to min if within snappable range (within min + snapOffset). 281 | // 4. Actually adjust each element in the pair to offset. 282 | // 283 | // --------------------------------------------------------------------- 284 | // | | <- a.minSize || b.minSize -> | | 285 | // | | | <- this.snapOffset || this.snapOffset -> | | | 286 | // | | | || | | | 287 | // | | | || | | | 288 | // --------------------------------------------------------------------- 289 | // | <- this.start this.size -> | 290 | function drag(e) { 291 | var offset; 292 | var a = elements[this.a]; 293 | var b = elements[this.b]; 294 | 295 | if (!this.dragging) { return } 296 | 297 | // Get the offset of the event from the first side of the 298 | // pair `this.start`. Then offset by the initial position of the 299 | // mouse compared to the gutter size. 300 | offset = 301 | getMousePosition(e) - 302 | this.start + 303 | (this[aGutterSize] - this.dragOffset); 304 | 305 | if (dragInterval > 1) { 306 | offset = Math.round(offset / dragInterval) * dragInterval; 307 | } 308 | 309 | // If within snapOffset of min or max, set offset to min or max. 310 | // snapOffset buffers a.minSize and b.minSize, so logic is opposite for both. 311 | // Include the appropriate gutter sizes to prevent overflows. 312 | if (offset <= a.minSize + snapOffset + this[aGutterSize]) { 313 | offset = a.minSize + this[aGutterSize]; 314 | } else if ( 315 | offset >= 316 | this.size - (b.minSize + snapOffset + this[bGutterSize]) 317 | ) { 318 | offset = this.size - (b.minSize + this[bGutterSize]); 319 | } 320 | 321 | // Actually adjust the size. 322 | adjust.call(this, offset); 323 | 324 | // Call the drag callback continously. Don't do anything too intensive 325 | // in this callback. 326 | getOption(options, 'onDrag', NOOP)(); 327 | } 328 | 329 | // Cache some important sizes when drag starts, so we don't have to do that 330 | // continously: 331 | // 332 | // `size`: The total size of the pair. First + second + first gutter + second gutter. 333 | // `start`: The leading side of the first element. 334 | // 335 | // ------------------------------------------------ 336 | // | aGutterSize -> ||| | 337 | // | ||| | 338 | // | ||| | 339 | // | ||| <- bGutterSize | 340 | // ------------------------------------------------ 341 | // | <- start size -> | 342 | function calculateSizes() { 343 | // Figure out the parent size minus padding. 344 | var a = elements[this.a].element; 345 | var b = elements[this.b].element; 346 | 347 | var aBounds = a[getBoundingClientRect](); 348 | var bBounds = b[getBoundingClientRect](); 349 | 350 | this.size = 351 | aBounds[dimension] + 352 | bBounds[dimension] + 353 | this[aGutterSize] + 354 | this[bGutterSize]; 355 | this.start = aBounds[position]; 356 | this.end = aBounds[positionEnd]; 357 | } 358 | 359 | function innerSize(element) { 360 | // Return nothing if getComputedStyle is not supported (< IE9) 361 | // Or if parent element has no layout yet 362 | if (!getComputedStyle) { return null } 363 | 364 | var computedStyle = getComputedStyle(element); 365 | 366 | if (!computedStyle) { return null } 367 | 368 | var size = element[clientSize]; 369 | 370 | if (size === 0) { return null } 371 | 372 | if (direction === HORIZONTAL) { 373 | size -= 374 | parseFloat(computedStyle.paddingLeft) + 375 | parseFloat(computedStyle.paddingRight); 376 | } else { 377 | size -= 378 | parseFloat(computedStyle.paddingTop) + 379 | parseFloat(computedStyle.paddingBottom); 380 | } 381 | 382 | return size 383 | } 384 | 385 | // When specifying percentage sizes that are less than the computed 386 | // size of the element minus the gutter, the lesser percentages must be increased 387 | // (and decreased from the other elements) to make space for the pixels 388 | // subtracted by the gutters. 389 | function trimToMin(sizesToTrim) { 390 | // Try to get inner size of parent element. 391 | // If it's no supported, return original sizes. 392 | var parentSize = innerSize(parent); 393 | if (parentSize === null) { 394 | return sizesToTrim 395 | } 396 | 397 | if (minSizes.reduce(function (a, b) { return a + b; }, 0) > parentSize) { 398 | return sizesToTrim 399 | } 400 | 401 | // Keep track of the excess pixels, the amount of pixels over the desired percentage 402 | // Also keep track of the elements with pixels to spare, to decrease after if needed 403 | var excessPixels = 0; 404 | var toSpare = []; 405 | 406 | var pixelSizes = sizesToTrim.map(function (size, i) { 407 | // Convert requested percentages to pixel sizes 408 | var pixelSize = (parentSize * size) / 100; 409 | var elementGutterSize = getGutterSize( 410 | gutterSize, 411 | i === 0, 412 | i === sizesToTrim.length - 1, 413 | gutterAlign 414 | ); 415 | var elementMinSize = minSizes[i] + elementGutterSize; 416 | 417 | // If element is too smal, increase excess pixels by the difference 418 | // and mark that it has no pixels to spare 419 | if (pixelSize < elementMinSize) { 420 | excessPixels += elementMinSize - pixelSize; 421 | toSpare.push(0); 422 | return elementMinSize 423 | } 424 | 425 | // Otherwise, mark the pixels it has to spare and return it's original size 426 | toSpare.push(pixelSize - elementMinSize); 427 | return pixelSize 428 | }); 429 | 430 | // If nothing was adjusted, return the original sizes 431 | if (excessPixels === 0) { 432 | return sizesToTrim 433 | } 434 | 435 | return pixelSizes.map(function (pixelSize, i) { 436 | var newPixelSize = pixelSize; 437 | 438 | // While there's still pixels to take, and there's enough pixels to spare, 439 | // take as many as possible up to the total excess pixels 440 | if (excessPixels > 0 && toSpare[i] - excessPixels > 0) { 441 | var takenPixels = Math.min( 442 | excessPixels, 443 | toSpare[i] - excessPixels 444 | ); 445 | 446 | // Subtract the amount taken for the next iteration 447 | excessPixels -= takenPixels; 448 | newPixelSize = pixelSize - takenPixels; 449 | } 450 | 451 | // Return the pixel size adjusted as a percentage 452 | return (newPixelSize / parentSize) * 100 453 | }) 454 | } 455 | 456 | // stopDragging is very similar to startDragging in reverse. 457 | function stopDragging() { 458 | var self = this; 459 | var a = elements[self.a].element; 460 | var b = elements[self.b].element; 461 | 462 | if (self.dragging) { 463 | getOption(options, 'onDragEnd', NOOP)(getSizes()); 464 | } 465 | 466 | self.dragging = false; 467 | 468 | // Remove the stored event listeners. This is why we store them. 469 | global[removeEventListener]('mouseup', self.stop); 470 | global[removeEventListener]('touchend', self.stop); 471 | global[removeEventListener]('touchcancel', self.stop); 472 | global[removeEventListener]('mousemove', self.move); 473 | global[removeEventListener]('touchmove', self.move); 474 | 475 | // Clear bound function references 476 | self.stop = null; 477 | self.move = null; 478 | 479 | a[removeEventListener]('selectstart', NOOP); 480 | a[removeEventListener]('dragstart', NOOP); 481 | b[removeEventListener]('selectstart', NOOP); 482 | b[removeEventListener]('dragstart', NOOP); 483 | 484 | a.style.userSelect = ''; 485 | a.style.webkitUserSelect = ''; 486 | a.style.MozUserSelect = ''; 487 | a.style.pointerEvents = ''; 488 | 489 | b.style.userSelect = ''; 490 | b.style.webkitUserSelect = ''; 491 | b.style.MozUserSelect = ''; 492 | b.style.pointerEvents = ''; 493 | 494 | self.gutter.style.cursor = ''; 495 | self.parent.style.cursor = ''; 496 | document.body.style.cursor = ''; 497 | } 498 | 499 | // startDragging calls `calculateSizes` to store the inital size in the pair object. 500 | // It also adds event listeners for mouse/touch events, 501 | // and prevents selection while dragging so avoid the selecting text. 502 | function startDragging(e) { 503 | // Right-clicking can't start dragging. 504 | if ('button' in e && e.button !== 0) { 505 | return 506 | } 507 | 508 | // Alias frequently used variables to save space. 200 bytes. 509 | var self = this; 510 | var a = elements[self.a].element; 511 | var b = elements[self.b].element; 512 | 513 | // Call the onDragStart callback. 514 | if (!self.dragging) { 515 | getOption(options, 'onDragStart', NOOP)(getSizes()); 516 | } 517 | 518 | // Don't actually drag the element. We emulate that in the drag function. 519 | e.preventDefault(); 520 | 521 | // Set the dragging property of the pair object. 522 | self.dragging = true; 523 | 524 | // Create two event listeners bound to the same pair object and store 525 | // them in the pair object. 526 | self.move = drag.bind(self); 527 | self.stop = stopDragging.bind(self); 528 | 529 | // All the binding. `window` gets the stop events in case we drag out of the elements. 530 | global[addEventListener]('mouseup', self.stop); 531 | global[addEventListener]('touchend', self.stop); 532 | global[addEventListener]('touchcancel', self.stop); 533 | global[addEventListener]('mousemove', self.move); 534 | global[addEventListener]('touchmove', self.move); 535 | 536 | // Disable selection. Disable! 537 | a[addEventListener]('selectstart', NOOP); 538 | a[addEventListener]('dragstart', NOOP); 539 | b[addEventListener]('selectstart', NOOP); 540 | b[addEventListener]('dragstart', NOOP); 541 | 542 | a.style.userSelect = 'none'; 543 | a.style.webkitUserSelect = 'none'; 544 | a.style.MozUserSelect = 'none'; 545 | a.style.pointerEvents = 'none'; 546 | 547 | b.style.userSelect = 'none'; 548 | b.style.webkitUserSelect = 'none'; 549 | b.style.MozUserSelect = 'none'; 550 | b.style.pointerEvents = 'none'; 551 | 552 | // Set the cursor at multiple levels 553 | self.gutter.style.cursor = cursor; 554 | self.parent.style.cursor = cursor; 555 | document.body.style.cursor = cursor; 556 | 557 | // Cache the initial sizes of the pair. 558 | calculateSizes.call(self); 559 | 560 | // Determine the position of the mouse compared to the gutter 561 | self.dragOffset = getMousePosition(e) - self.end; 562 | } 563 | 564 | // adjust sizes to ensure percentage is within min size and gutter. 565 | sizes = trimToMin(sizes); 566 | 567 | // 5. Create pair and element objects. Each pair has an index reference to 568 | // elements `a` and `b` of the pair (first and second elements). 569 | // Loop through the elements while pairing them off. Every pair gets a 570 | // `pair` object and a gutter. 571 | // 572 | // Basic logic: 573 | // 574 | // - Starting with the second element `i > 0`, create `pair` objects with 575 | // `a = i - 1` and `b = i` 576 | // - Set gutter sizes based on the _pair_ being first/last. The first and last 577 | // pair have gutterSize / 2, since they only have one half gutter, and not two. 578 | // - Create gutter elements and add event listeners. 579 | // - Set the size of the elements, minus the gutter sizes. 580 | // 581 | // ----------------------------------------------------------------------- 582 | // | i=0 | i=1 | i=2 | i=3 | 583 | // | | | | | 584 | // | pair 0 pair 1 pair 2 | 585 | // | | | | | 586 | // ----------------------------------------------------------------------- 587 | var pairs = []; 588 | elements = ids.map(function (id, i) { 589 | // Create the element object. 590 | var element = { 591 | element: elementOrSelector(id), 592 | size: sizes[i], 593 | minSize: minSizes[i], 594 | i: i, 595 | }; 596 | 597 | var pair; 598 | 599 | if (i > 0) { 600 | // Create the pair object with its metadata. 601 | pair = { 602 | a: i - 1, 603 | b: i, 604 | dragging: false, 605 | direction: direction, 606 | parent: parent, 607 | }; 608 | 609 | pair[aGutterSize] = getGutterSize( 610 | gutterSize, 611 | i - 1 === 0, 612 | false, 613 | gutterAlign 614 | ); 615 | pair[bGutterSize] = getGutterSize( 616 | gutterSize, 617 | false, 618 | i === ids.length - 1, 619 | gutterAlign 620 | ); 621 | 622 | // if the parent has a reverse flex-direction, switch the pair elements. 623 | if ( 624 | parentFlexDirection === 'row-reverse' || 625 | parentFlexDirection === 'column-reverse' 626 | ) { 627 | var temp = pair.a; 628 | pair.a = pair.b; 629 | pair.b = temp; 630 | } 631 | } 632 | 633 | // Determine the size of the current element. IE8 is supported by 634 | // staticly assigning sizes without draggable gutters. Assigns a string 635 | // to `size`. 636 | // 637 | // Create gutter elements for each pair. 638 | if (i > 0) { 639 | var gutterElement = gutter(i, direction, element.element); 640 | setGutterSize(gutterElement, gutterSize, i); 641 | 642 | // Save bound event listener for removal later 643 | pair[gutterStartDragging] = startDragging.bind(pair); 644 | 645 | // Attach bound event listener 646 | gutterElement[addEventListener]( 647 | 'mousedown', 648 | pair[gutterStartDragging] 649 | ); 650 | gutterElement[addEventListener]( 651 | 'touchstart', 652 | pair[gutterStartDragging] 653 | ); 654 | 655 | parent.insertBefore(gutterElement, element.element); 656 | 657 | pair.gutter = gutterElement; 658 | } 659 | 660 | setElementSize( 661 | element.element, 662 | element.size, 663 | getGutterSize( 664 | gutterSize, 665 | i === 0, 666 | i === ids.length - 1, 667 | gutterAlign 668 | ), 669 | i 670 | ); 671 | 672 | // After the first iteration, and we have a pair object, append it to the 673 | // list of pairs. 674 | if (i > 0) { 675 | pairs.push(pair); 676 | } 677 | 678 | return element 679 | }); 680 | 681 | function adjustToMin(element) { 682 | var isLast = element.i === pairs.length; 683 | var pair = isLast ? pairs[element.i - 1] : pairs[element.i]; 684 | 685 | calculateSizes.call(pair); 686 | 687 | var size = isLast 688 | ? pair.size - element.minSize - pair[bGutterSize] 689 | : element.minSize + pair[aGutterSize]; 690 | 691 | adjust.call(pair, size); 692 | } 693 | 694 | elements.forEach(function (element) { 695 | var computedSize = element.element[getBoundingClientRect]()[dimension]; 696 | 697 | if (computedSize < element.minSize) { 698 | if (expandToMin) { 699 | adjustToMin(element); 700 | } else { 701 | // eslint-disable-next-line no-param-reassign 702 | element.minSize = computedSize; 703 | } 704 | } 705 | }); 706 | 707 | function setSizes(newSizes) { 708 | var trimmed = trimToMin(newSizes); 709 | trimmed.forEach(function (newSize, i) { 710 | if (i > 0) { 711 | var pair = pairs[i - 1]; 712 | 713 | var a = elements[pair.a]; 714 | var b = elements[pair.b]; 715 | 716 | a.size = trimmed[i - 1]; 717 | b.size = newSize; 718 | 719 | setElementSize(a.element, a.size, pair[aGutterSize], a.i); 720 | setElementSize(b.element, b.size, pair[bGutterSize], b.i); 721 | } 722 | }); 723 | } 724 | 725 | function destroy(preserveStyles, preserveGutter) { 726 | pairs.forEach(function (pair) { 727 | if (preserveGutter !== true) { 728 | pair.parent.removeChild(pair.gutter); 729 | } else { 730 | pair.gutter[removeEventListener]( 731 | 'mousedown', 732 | pair[gutterStartDragging] 733 | ); 734 | pair.gutter[removeEventListener]( 735 | 'touchstart', 736 | pair[gutterStartDragging] 737 | ); 738 | } 739 | 740 | if (preserveStyles !== true) { 741 | var style = elementStyle( 742 | dimension, 743 | pair.a.size, 744 | pair[aGutterSize] 745 | ); 746 | 747 | Object.keys(style).forEach(function (prop) { 748 | elements[pair.a].element.style[prop] = ''; 749 | elements[pair.b].element.style[prop] = ''; 750 | }); 751 | } 752 | }); 753 | } 754 | 755 | return { 756 | setSizes: setSizes, 757 | getSizes: getSizes, 758 | collapse: function collapse(i) { 759 | adjustToMin(elements[i]); 760 | }, 761 | destroy: destroy, 762 | parent: parent, 763 | pairs: pairs, 764 | } 765 | }; 766 | 767 | return Split; 768 | 769 | }))); -------------------------------------------------------------------------------- /click_web/static/pure.css: -------------------------------------------------------------------------------- 1 | /*! 2 | Pure v2.0.6 3 | Copyright 2013 Yahoo! 4 | Licensed under the BSD License. 5 | https://github.com/pure-css/pure/blob/master/LICENSE 6 | */ 7 | /*! 8 | normalize.css v | MIT License | git.io/normalize 9 | Copyright (c) Nicolas Gallagher and Jonathan Neal 10 | */ 11 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ 12 | 13 | /* Document 14 | ========================================================================== */ 15 | 16 | /** 17 | * 1. Correct the line height in all browsers. 18 | * 2. Prevent adjustments of font size after orientation changes in iOS. 19 | */ 20 | 21 | html { 22 | line-height: 1.15; /* 1 */ 23 | -webkit-text-size-adjust: 100%; /* 2 */ 24 | } 25 | 26 | /* Sections 27 | ========================================================================== */ 28 | 29 | /** 30 | * Remove the margin in all browsers. 31 | */ 32 | 33 | body { 34 | margin: 0; 35 | } 36 | 37 | /** 38 | * Render the `main` element consistently in IE. 39 | */ 40 | 41 | main { 42 | display: block; 43 | } 44 | 45 | /** 46 | * Correct the font size and margin on `h1` elements within `section` and 47 | * `article` contexts in Chrome, Firefox, and Safari. 48 | */ 49 | 50 | h1 { 51 | font-size: 2em; 52 | margin: 0.67em 0; 53 | } 54 | 55 | /* Grouping content 56 | ========================================================================== */ 57 | 58 | /** 59 | * 1. Add the correct box sizing in Firefox. 60 | * 2. Show the overflow in Edge and IE. 61 | */ 62 | 63 | hr { 64 | -webkit-box-sizing: content-box; 65 | box-sizing: content-box; /* 1 */ 66 | height: 0; /* 1 */ 67 | overflow: visible; /* 2 */ 68 | } 69 | 70 | /** 71 | * 1. Correct the inheritance and scaling of font size in all browsers. 72 | * 2. Correct the odd `em` font sizing in all browsers. 73 | */ 74 | 75 | pre { 76 | font-family: monospace, monospace; /* 1 */ 77 | font-size: 1em; /* 2 */ 78 | } 79 | 80 | /* Text-level semantics 81 | ========================================================================== */ 82 | 83 | /** 84 | * Remove the gray background on active links in IE 10. 85 | */ 86 | 87 | a { 88 | background-color: transparent; 89 | } 90 | 91 | /** 92 | * 1. Remove the bottom border in Chrome 57- 93 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 94 | */ 95 | 96 | abbr[title] { 97 | border-bottom: none; /* 1 */ 98 | text-decoration: underline; /* 2 */ 99 | -webkit-text-decoration: underline dotted; 100 | text-decoration: underline dotted; /* 2 */ 101 | } 102 | 103 | /** 104 | * Add the correct font weight in Chrome, Edge, and Safari. 105 | */ 106 | 107 | b, 108 | strong { 109 | font-weight: bolder; 110 | } 111 | 112 | /** 113 | * 1. Correct the inheritance and scaling of font size in all browsers. 114 | * 2. Correct the odd `em` font sizing in all browsers. 115 | */ 116 | 117 | code, 118 | kbd, 119 | samp { 120 | font-family: monospace, monospace; /* 1 */ 121 | font-size: 1em; /* 2 */ 122 | } 123 | 124 | /** 125 | * Add the correct font size in all browsers. 126 | */ 127 | 128 | small { 129 | font-size: 80%; 130 | } 131 | 132 | /** 133 | * Prevent `sub` and `sup` elements from affecting the line height in 134 | * all browsers. 135 | */ 136 | 137 | sub, 138 | sup { 139 | font-size: 75%; 140 | line-height: 0; 141 | position: relative; 142 | vertical-align: baseline; 143 | } 144 | 145 | sub { 146 | bottom: -0.25em; 147 | } 148 | 149 | sup { 150 | top: -0.5em; 151 | } 152 | 153 | /* Embedded content 154 | ========================================================================== */ 155 | 156 | /** 157 | * Remove the border on images inside links in IE 10. 158 | */ 159 | 160 | img { 161 | border-style: none; 162 | } 163 | 164 | /* Forms 165 | ========================================================================== */ 166 | 167 | /** 168 | * 1. Change the font styles in all browsers. 169 | * 2. Remove the margin in Firefox and Safari. 170 | */ 171 | 172 | button, 173 | input, 174 | optgroup, 175 | select, 176 | textarea { 177 | font-family: inherit; /* 1 */ 178 | font-size: 100%; /* 1 */ 179 | line-height: 1.15; /* 1 */ 180 | margin: 0; /* 2 */ 181 | } 182 | 183 | /** 184 | * Show the overflow in IE. 185 | * 1. Show the overflow in Edge. 186 | */ 187 | 188 | button, 189 | input { /* 1 */ 190 | overflow: visible; 191 | } 192 | 193 | /** 194 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 195 | * 1. Remove the inheritance of text transform in Firefox. 196 | */ 197 | 198 | button, 199 | select { /* 1 */ 200 | text-transform: none; 201 | } 202 | 203 | /** 204 | * Correct the inability to style clickable types in iOS and Safari. 205 | */ 206 | 207 | button, 208 | [type="button"], 209 | [type="reset"], 210 | [type="submit"] { 211 | -webkit-appearance: button; 212 | } 213 | 214 | /** 215 | * Remove the inner border and padding in Firefox. 216 | */ 217 | 218 | button::-moz-focus-inner, 219 | [type="button"]::-moz-focus-inner, 220 | [type="reset"]::-moz-focus-inner, 221 | [type="submit"]::-moz-focus-inner { 222 | border-style: none; 223 | padding: 0; 224 | } 225 | 226 | /** 227 | * Restore the focus styles unset by the previous rule. 228 | */ 229 | 230 | button:-moz-focusring, 231 | [type="button"]:-moz-focusring, 232 | [type="reset"]:-moz-focusring, 233 | [type="submit"]:-moz-focusring { 234 | outline: 1px dotted ButtonText; 235 | } 236 | 237 | /** 238 | * Correct the padding in Firefox. 239 | */ 240 | 241 | fieldset { 242 | padding: 0.35em 0.75em 0.625em; 243 | } 244 | 245 | /** 246 | * 1. Correct the text wrapping in Edge and IE. 247 | * 2. Correct the color inheritance from `fieldset` elements in IE. 248 | * 3. Remove the padding so developers are not caught out when they zero out 249 | * `fieldset` elements in all browsers. 250 | */ 251 | 252 | legend { 253 | -webkit-box-sizing: border-box; 254 | box-sizing: border-box; /* 1 */ 255 | color: inherit; /* 2 */ 256 | display: table; /* 1 */ 257 | max-width: 100%; /* 1 */ 258 | padding: 0; /* 3 */ 259 | white-space: normal; /* 1 */ 260 | } 261 | 262 | /** 263 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 264 | */ 265 | 266 | progress { 267 | vertical-align: baseline; 268 | } 269 | 270 | /** 271 | * Remove the default vertical scrollbar in IE 10+. 272 | */ 273 | 274 | textarea { 275 | overflow: auto; 276 | } 277 | 278 | /** 279 | * 1. Add the correct box sizing in IE 10. 280 | * 2. Remove the padding in IE 10. 281 | */ 282 | 283 | [type="checkbox"], 284 | [type="radio"] { 285 | -webkit-box-sizing: border-box; 286 | box-sizing: border-box; /* 1 */ 287 | padding: 0; /* 2 */ 288 | } 289 | 290 | /** 291 | * Correct the cursor style of increment and decrement buttons in Chrome. 292 | */ 293 | 294 | [type="number"]::-webkit-inner-spin-button, 295 | [type="number"]::-webkit-outer-spin-button { 296 | height: auto; 297 | } 298 | 299 | /** 300 | * 1. Correct the odd appearance in Chrome and Safari. 301 | * 2. Correct the outline style in Safari. 302 | */ 303 | 304 | [type="search"] { 305 | -webkit-appearance: textfield; /* 1 */ 306 | outline-offset: -2px; /* 2 */ 307 | } 308 | 309 | /** 310 | * Remove the inner padding in Chrome and Safari on macOS. 311 | */ 312 | 313 | [type="search"]::-webkit-search-decoration { 314 | -webkit-appearance: none; 315 | } 316 | 317 | /** 318 | * 1. Correct the inability to style clickable types in iOS and Safari. 319 | * 2. Change font properties to `inherit` in Safari. 320 | */ 321 | 322 | ::-webkit-file-upload-button { 323 | -webkit-appearance: button; /* 1 */ 324 | font: inherit; /* 2 */ 325 | } 326 | 327 | /* Interactive 328 | ========================================================================== */ 329 | 330 | /* 331 | * Add the correct display in Edge, IE 10+, and Firefox. 332 | */ 333 | 334 | details { 335 | display: block; 336 | } 337 | 338 | /* 339 | * Add the correct display in all browsers. 340 | */ 341 | 342 | summary { 343 | display: list-item; 344 | } 345 | 346 | /* Misc 347 | ========================================================================== */ 348 | 349 | /** 350 | * Add the correct display in IE 10+. 351 | */ 352 | 353 | template { 354 | display: none; 355 | } 356 | 357 | /** 358 | * Add the correct display in IE 10. 359 | */ 360 | 361 | [hidden] { 362 | display: none; 363 | } 364 | 365 | /*csslint important:false*/ 366 | 367 | /* ========================================================================== 368 | Pure Base Extras 369 | ========================================================================== */ 370 | 371 | /** 372 | * Extra rules that Pure adds on top of Normalize.css 373 | */ 374 | 375 | html { 376 | font-family: sans-serif; 377 | } 378 | 379 | /** 380 | * Always hide an element when it has the `hidden` HTML attribute. 381 | */ 382 | 383 | .hidden, 384 | [hidden] { 385 | display: none !important; 386 | } 387 | 388 | /** 389 | * Add this class to an image to make it fit within it's fluid parent wrapper while maintaining 390 | * aspect ratio. 391 | */ 392 | .pure-img { 393 | max-width: 100%; 394 | height: auto; 395 | display: block; 396 | } 397 | 398 | /*csslint regex-selectors:false, known-properties:false, duplicate-properties:false*/ 399 | 400 | .pure-g { 401 | letter-spacing: -0.31em; /* Webkit: collapse white-space between units */ 402 | text-rendering: optimizespeed; /* Webkit: fixes text-rendering: optimizeLegibility */ 403 | 404 | /* 405 | Sets the font stack to fonts known to work properly with the above letter 406 | and word spacings. See: https://github.com/pure-css/pure/issues/41/ 407 | 408 | The following font stack makes Pure Grids work on all known environments. 409 | 410 | * FreeSans: Ships with many Linux distros, including Ubuntu 411 | 412 | * Arimo: Ships with Chrome OS. Arimo has to be defined before Helvetica and 413 | Arial to get picked up by the browser, even though neither is available 414 | in Chrome OS. 415 | 416 | * Droid Sans: Ships with all versions of Android. 417 | 418 | * Helvetica, Arial, sans-serif: Common font stack on OS X and Windows. 419 | */ 420 | font-family: FreeSans, Arimo, "Droid Sans", Helvetica, Arial, sans-serif; 421 | 422 | /* Use flexbox when possible to avoid `letter-spacing` side-effects. */ 423 | display: -webkit-box; 424 | display: -ms-flexbox; 425 | display: flex; 426 | -webkit-box-orient: horizontal; 427 | -webkit-box-direction: normal; 428 | -ms-flex-flow: row wrap; 429 | flex-flow: row wrap; 430 | 431 | /* Prevents distributing space between rows */ 432 | -ms-flex-line-pack: start; 433 | align-content: flex-start; 434 | } 435 | 436 | /* IE10 display: -ms-flexbox (and display: flex in IE 11) does not work inside a table; fall back to block and rely on font hack */ 437 | @media all and (-ms-high-contrast: none), (-ms-high-contrast: active) { 438 | table .pure-g { 439 | display: block; 440 | } 441 | } 442 | 443 | /* Opera as of 12 on Windows needs word-spacing. 444 | The ".opera-only" selector is used to prevent actual prefocus styling 445 | and is not required in markup. 446 | */ 447 | .opera-only :-o-prefocus, 448 | .pure-g { 449 | word-spacing: -0.43em; 450 | } 451 | 452 | .pure-u { 453 | display: inline-block; 454 | letter-spacing: normal; 455 | word-spacing: normal; 456 | vertical-align: top; 457 | text-rendering: auto; 458 | } 459 | 460 | /* 461 | Resets the font family back to the OS/browser's default sans-serif font, 462 | this the same font stack that Normalize.css sets for the `body`. 463 | */ 464 | .pure-g [class *= "pure-u"] { 465 | font-family: sans-serif; 466 | } 467 | 468 | .pure-u-1, 469 | .pure-u-1-1, 470 | .pure-u-1-2, 471 | .pure-u-1-3, 472 | .pure-u-2-3, 473 | .pure-u-1-4, 474 | .pure-u-3-4, 475 | .pure-u-1-5, 476 | .pure-u-2-5, 477 | .pure-u-3-5, 478 | .pure-u-4-5, 479 | .pure-u-5-5, 480 | .pure-u-1-6, 481 | .pure-u-5-6, 482 | .pure-u-1-8, 483 | .pure-u-3-8, 484 | .pure-u-5-8, 485 | .pure-u-7-8, 486 | .pure-u-1-12, 487 | .pure-u-5-12, 488 | .pure-u-7-12, 489 | .pure-u-11-12, 490 | .pure-u-1-24, 491 | .pure-u-2-24, 492 | .pure-u-3-24, 493 | .pure-u-4-24, 494 | .pure-u-5-24, 495 | .pure-u-6-24, 496 | .pure-u-7-24, 497 | .pure-u-8-24, 498 | .pure-u-9-24, 499 | .pure-u-10-24, 500 | .pure-u-11-24, 501 | .pure-u-12-24, 502 | .pure-u-13-24, 503 | .pure-u-14-24, 504 | .pure-u-15-24, 505 | .pure-u-16-24, 506 | .pure-u-17-24, 507 | .pure-u-18-24, 508 | .pure-u-19-24, 509 | .pure-u-20-24, 510 | .pure-u-21-24, 511 | .pure-u-22-24, 512 | .pure-u-23-24, 513 | .pure-u-24-24 { 514 | display: inline-block; 515 | letter-spacing: normal; 516 | word-spacing: normal; 517 | vertical-align: top; 518 | text-rendering: auto; 519 | } 520 | 521 | .pure-u-1-24 { 522 | width: 4.1667%; 523 | } 524 | 525 | .pure-u-1-12, 526 | .pure-u-2-24 { 527 | width: 8.3333%; 528 | } 529 | 530 | .pure-u-1-8, 531 | .pure-u-3-24 { 532 | width: 12.5000%; 533 | } 534 | 535 | .pure-u-1-6, 536 | .pure-u-4-24 { 537 | width: 16.6667%; 538 | } 539 | 540 | .pure-u-1-5 { 541 | width: 20%; 542 | } 543 | 544 | .pure-u-5-24 { 545 | width: 20.8333%; 546 | } 547 | 548 | .pure-u-1-4, 549 | .pure-u-6-24 { 550 | width: 25%; 551 | } 552 | 553 | .pure-u-7-24 { 554 | width: 29.1667%; 555 | } 556 | 557 | .pure-u-1-3, 558 | .pure-u-8-24 { 559 | width: 33.3333%; 560 | } 561 | 562 | .pure-u-3-8, 563 | .pure-u-9-24 { 564 | width: 37.5000%; 565 | } 566 | 567 | .pure-u-2-5 { 568 | width: 40%; 569 | } 570 | 571 | .pure-u-5-12, 572 | .pure-u-10-24 { 573 | width: 41.6667%; 574 | } 575 | 576 | .pure-u-11-24 { 577 | width: 45.8333%; 578 | } 579 | 580 | .pure-u-1-2, 581 | .pure-u-12-24 { 582 | width: 50%; 583 | } 584 | 585 | .pure-u-13-24 { 586 | width: 54.1667%; 587 | } 588 | 589 | .pure-u-7-12, 590 | .pure-u-14-24 { 591 | width: 58.3333%; 592 | } 593 | 594 | .pure-u-3-5 { 595 | width: 60%; 596 | } 597 | 598 | .pure-u-5-8, 599 | .pure-u-15-24 { 600 | width: 62.5000%; 601 | } 602 | 603 | .pure-u-2-3, 604 | .pure-u-16-24 { 605 | width: 66.6667%; 606 | } 607 | 608 | .pure-u-17-24 { 609 | width: 70.8333%; 610 | } 611 | 612 | .pure-u-3-4, 613 | .pure-u-18-24 { 614 | width: 75%; 615 | } 616 | 617 | .pure-u-19-24 { 618 | width: 79.1667%; 619 | } 620 | 621 | .pure-u-4-5 { 622 | width: 80%; 623 | } 624 | 625 | .pure-u-5-6, 626 | .pure-u-20-24 { 627 | width: 83.3333%; 628 | } 629 | 630 | .pure-u-7-8, 631 | .pure-u-21-24 { 632 | width: 87.5000%; 633 | } 634 | 635 | .pure-u-11-12, 636 | .pure-u-22-24 { 637 | width: 91.6667%; 638 | } 639 | 640 | .pure-u-23-24 { 641 | width: 95.8333%; 642 | } 643 | 644 | .pure-u-1, 645 | .pure-u-1-1, 646 | .pure-u-5-5, 647 | .pure-u-24-24 { 648 | width: 100%; 649 | } 650 | .pure-button { 651 | /* Structure */ 652 | display: inline-block; 653 | line-height: normal; 654 | white-space: nowrap; 655 | vertical-align: middle; 656 | text-align: center; 657 | cursor: pointer; 658 | -webkit-user-drag: none; 659 | -webkit-user-select: none; 660 | -moz-user-select: none; 661 | -ms-user-select: none; 662 | user-select: none; 663 | -webkit-box-sizing: border-box; 664 | box-sizing: border-box; 665 | } 666 | 667 | /* Firefox: Get rid of the inner focus border */ 668 | .pure-button::-moz-focus-inner { 669 | padding: 0; 670 | border: 0; 671 | } 672 | 673 | /* Inherit .pure-g styles */ 674 | .pure-button-group { 675 | letter-spacing: -0.31em; /* Webkit: collapse white-space between units */ 676 | text-rendering: optimizespeed; /* Webkit: fixes text-rendering: optimizeLegibility */ 677 | } 678 | 679 | .opera-only :-o-prefocus, 680 | .pure-button-group { 681 | word-spacing: -0.43em; 682 | } 683 | 684 | .pure-button-group .pure-button { 685 | letter-spacing: normal; 686 | word-spacing: normal; 687 | vertical-align: top; 688 | text-rendering: auto; 689 | } 690 | 691 | /*csslint outline-none:false*/ 692 | 693 | .pure-button { 694 | font-family: inherit; 695 | font-size: 100%; 696 | padding: 0.5em 1em; 697 | color: rgba(0, 0, 0, 0.80); 698 | border: none rgba(0, 0, 0, 0); 699 | background-color: #E6E6E6; 700 | text-decoration: none; 701 | border-radius: 2px; 702 | } 703 | 704 | .pure-button-hover, 705 | .pure-button:hover, 706 | .pure-button:focus { 707 | background-image: -webkit-gradient(linear, left top, left bottom, from(transparent), color-stop(40%, rgba(0,0,0, 0.05)), to(rgba(0,0,0, 0.10))); 708 | background-image: linear-gradient(transparent, rgba(0,0,0, 0.05) 40%, rgba(0,0,0, 0.10)); 709 | } 710 | .pure-button:focus { 711 | outline: 0; 712 | } 713 | .pure-button-active, 714 | .pure-button:active { 715 | -webkit-box-shadow: 0 0 0 1px rgba(0,0,0, 0.15) inset, 0 0 6px rgba(0,0,0, 0.20) inset; 716 | box-shadow: 0 0 0 1px rgba(0,0,0, 0.15) inset, 0 0 6px rgba(0,0,0, 0.20) inset; 717 | border-color: #000; 718 | } 719 | 720 | .pure-button[disabled], 721 | .pure-button-disabled, 722 | .pure-button-disabled:hover, 723 | .pure-button-disabled:focus, 724 | .pure-button-disabled:active { 725 | border: none; 726 | background-image: none; 727 | opacity: 0.40; 728 | cursor: not-allowed; 729 | -webkit-box-shadow: none; 730 | box-shadow: none; 731 | pointer-events: none; 732 | } 733 | 734 | .pure-button-hidden { 735 | display: none; 736 | } 737 | 738 | .pure-button-primary, 739 | .pure-button-selected, 740 | a.pure-button-primary, 741 | a.pure-button-selected { 742 | background-color: rgb(0, 120, 231); 743 | color: #fff; 744 | } 745 | 746 | /* Button Groups */ 747 | .pure-button-group .pure-button { 748 | margin: 0; 749 | border-radius: 0; 750 | border-right: 1px solid rgba(0, 0, 0, 0.2); 751 | 752 | } 753 | 754 | .pure-button-group .pure-button:first-child { 755 | border-top-left-radius: 2px; 756 | border-bottom-left-radius: 2px; 757 | } 758 | .pure-button-group .pure-button:last-child { 759 | border-top-right-radius: 2px; 760 | border-bottom-right-radius: 2px; 761 | border-right: none; 762 | } 763 | 764 | /*csslint box-model:false*/ 765 | /* 766 | Box-model set to false because we're setting a height on select elements, which 767 | also have border and padding. This is done because some browsers don't render 768 | the padding. We explicitly set the box-model for select elements to border-box, 769 | so we can ignore the csslint warning. 770 | */ 771 | 772 | .pure-form input[type="text"], 773 | .pure-form input[type="password"], 774 | .pure-form input[type="email"], 775 | .pure-form input[type="url"], 776 | .pure-form input[type="date"], 777 | .pure-form input[type="month"], 778 | .pure-form input[type="time"], 779 | .pure-form input[type="datetime"], 780 | .pure-form input[type="datetime-local"], 781 | .pure-form input[type="week"], 782 | .pure-form input[type="number"], 783 | .pure-form input[type="search"], 784 | .pure-form input[type="tel"], 785 | .pure-form input[type="color"], 786 | .pure-form select, 787 | .pure-form textarea { 788 | padding: 0.5em 0.6em; 789 | display: inline-block; 790 | border: 1px solid #ccc; 791 | -webkit-box-shadow: inset 0 1px 3px #ddd; 792 | box-shadow: inset 0 1px 3px #ddd; 793 | border-radius: 4px; 794 | vertical-align: middle; 795 | -webkit-box-sizing: border-box; 796 | box-sizing: border-box; 797 | } 798 | 799 | /* 800 | Need to separate out the :not() selector from the rest of the CSS 2.1 selectors 801 | since IE8 won't execute CSS that contains a CSS3 selector. 802 | */ 803 | .pure-form input:not([type]) { 804 | padding: 0.5em 0.6em; 805 | display: inline-block; 806 | border: 1px solid #ccc; 807 | -webkit-box-shadow: inset 0 1px 3px #ddd; 808 | box-shadow: inset 0 1px 3px #ddd; 809 | border-radius: 4px; 810 | -webkit-box-sizing: border-box; 811 | box-sizing: border-box; 812 | } 813 | 814 | 815 | /* Chrome (as of v.32/34 on OS X) needs additional room for color to display. */ 816 | /* May be able to remove this tweak as color inputs become more standardized across browsers. */ 817 | .pure-form input[type="color"] { 818 | padding: 0.2em 0.5em; 819 | } 820 | 821 | 822 | .pure-form input[type="text"]:focus, 823 | .pure-form input[type="password"]:focus, 824 | .pure-form input[type="email"]:focus, 825 | .pure-form input[type="url"]:focus, 826 | .pure-form input[type="date"]:focus, 827 | .pure-form input[type="month"]:focus, 828 | .pure-form input[type="time"]:focus, 829 | .pure-form input[type="datetime"]:focus, 830 | .pure-form input[type="datetime-local"]:focus, 831 | .pure-form input[type="week"]:focus, 832 | .pure-form input[type="number"]:focus, 833 | .pure-form input[type="search"]:focus, 834 | .pure-form input[type="tel"]:focus, 835 | .pure-form input[type="color"]:focus, 836 | .pure-form select:focus, 837 | .pure-form textarea:focus { 838 | outline: 0; 839 | border-color: #129FEA; 840 | } 841 | 842 | /* 843 | Need to separate out the :not() selector from the rest of the CSS 2.1 selectors 844 | since IE8 won't execute CSS that contains a CSS3 selector. 845 | */ 846 | .pure-form input:not([type]):focus { 847 | outline: 0; 848 | border-color: #129FEA; 849 | } 850 | 851 | .pure-form input[type="file"]:focus, 852 | .pure-form input[type="radio"]:focus, 853 | .pure-form input[type="checkbox"]:focus { 854 | outline: thin solid #129FEA; 855 | outline: 1px auto #129FEA; 856 | } 857 | .pure-form .pure-checkbox, 858 | .pure-form .pure-radio { 859 | margin: 0.5em 0; 860 | display: block; 861 | } 862 | 863 | .pure-form input[type="text"][disabled], 864 | .pure-form input[type="password"][disabled], 865 | .pure-form input[type="email"][disabled], 866 | .pure-form input[type="url"][disabled], 867 | .pure-form input[type="date"][disabled], 868 | .pure-form input[type="month"][disabled], 869 | .pure-form input[type="time"][disabled], 870 | .pure-form input[type="datetime"][disabled], 871 | .pure-form input[type="datetime-local"][disabled], 872 | .pure-form input[type="week"][disabled], 873 | .pure-form input[type="number"][disabled], 874 | .pure-form input[type="search"][disabled], 875 | .pure-form input[type="tel"][disabled], 876 | .pure-form input[type="color"][disabled], 877 | .pure-form select[disabled], 878 | .pure-form textarea[disabled] { 879 | cursor: not-allowed; 880 | background-color: #eaeded; 881 | color: #cad2d3; 882 | } 883 | 884 | /* 885 | Need to separate out the :not() selector from the rest of the CSS 2.1 selectors 886 | since IE8 won't execute CSS that contains a CSS3 selector. 887 | */ 888 | .pure-form input:not([type])[disabled] { 889 | cursor: not-allowed; 890 | background-color: #eaeded; 891 | color: #cad2d3; 892 | } 893 | .pure-form input[readonly], 894 | .pure-form select[readonly], 895 | .pure-form textarea[readonly] { 896 | background-color: #eee; /* menu hover bg color */ 897 | color: #777; /* menu text color */ 898 | border-color: #ccc; 899 | } 900 | 901 | .pure-form input:focus:invalid, 902 | .pure-form textarea:focus:invalid, 903 | .pure-form select:focus:invalid { 904 | color: #b94a48; 905 | border-color: #e9322d; 906 | } 907 | .pure-form input[type="file"]:focus:invalid:focus, 908 | .pure-form input[type="radio"]:focus:invalid:focus, 909 | .pure-form input[type="checkbox"]:focus:invalid:focus { 910 | outline-color: #e9322d; 911 | } 912 | .pure-form select { 913 | /* Normalizes the height; padding is not sufficient. */ 914 | height: 2.25em; 915 | border: 1px solid #ccc; 916 | background-color: white; 917 | } 918 | .pure-form select[multiple] { 919 | height: auto; 920 | } 921 | .pure-form label { 922 | margin: 0.5em 0 0.2em; 923 | } 924 | .pure-form fieldset { 925 | margin: 0; 926 | padding: 0.35em 0 0.75em; 927 | border: 0; 928 | } 929 | .pure-form legend { 930 | display: block; 931 | width: 100%; 932 | padding: 0.3em 0; 933 | margin-bottom: 0.3em; 934 | color: #333; 935 | border-bottom: 1px solid #e5e5e5; 936 | } 937 | 938 | .pure-form-stacked input[type="text"], 939 | .pure-form-stacked input[type="password"], 940 | .pure-form-stacked input[type="email"], 941 | .pure-form-stacked input[type="url"], 942 | .pure-form-stacked input[type="date"], 943 | .pure-form-stacked input[type="month"], 944 | .pure-form-stacked input[type="time"], 945 | .pure-form-stacked input[type="datetime"], 946 | .pure-form-stacked input[type="datetime-local"], 947 | .pure-form-stacked input[type="week"], 948 | .pure-form-stacked input[type="number"], 949 | .pure-form-stacked input[type="search"], 950 | .pure-form-stacked input[type="tel"], 951 | .pure-form-stacked input[type="color"], 952 | .pure-form-stacked input[type="file"], 953 | .pure-form-stacked select, 954 | .pure-form-stacked label, 955 | .pure-form-stacked textarea { 956 | display: block; 957 | margin: 0.25em 0; 958 | } 959 | 960 | /* 961 | Need to separate out the :not() selector from the rest of the CSS 2.1 selectors 962 | since IE8 won't execute CSS that contains a CSS3 selector. 963 | */ 964 | .pure-form-stacked input:not([type]) { 965 | display: block; 966 | margin: 0.25em 0; 967 | } 968 | .pure-form-aligned input, 969 | .pure-form-aligned textarea, 970 | .pure-form-aligned select, 971 | .pure-form-message-inline { 972 | display: inline-block; 973 | vertical-align: middle; 974 | } 975 | .pure-form-aligned textarea { 976 | vertical-align: top; 977 | } 978 | 979 | /* Aligned Forms */ 980 | .pure-form-aligned .pure-control-group { 981 | margin-bottom: 0.5em; 982 | } 983 | .pure-form-aligned .pure-control-group label { 984 | text-align: right; 985 | display: inline-block; 986 | vertical-align: middle; 987 | width: 10em; 988 | margin: 0 1em 0 0; 989 | } 990 | .pure-form-aligned .pure-controls { 991 | margin: 1.5em 0 0 11em; 992 | } 993 | 994 | /* Rounded Inputs */ 995 | .pure-form input.pure-input-rounded, 996 | .pure-form .pure-input-rounded { 997 | border-radius: 2em; 998 | padding: 0.5em 1em; 999 | } 1000 | 1001 | /* Grouped Inputs */ 1002 | .pure-form .pure-group fieldset { 1003 | margin-bottom: 10px; 1004 | } 1005 | .pure-form .pure-group input, 1006 | .pure-form .pure-group textarea { 1007 | display: block; 1008 | padding: 10px; 1009 | margin: 0 0 -1px; 1010 | border-radius: 0; 1011 | position: relative; 1012 | top: -1px; 1013 | } 1014 | .pure-form .pure-group input:focus, 1015 | .pure-form .pure-group textarea:focus { 1016 | z-index: 3; 1017 | } 1018 | .pure-form .pure-group input:first-child, 1019 | .pure-form .pure-group textarea:first-child { 1020 | top: 1px; 1021 | border-radius: 4px 4px 0 0; 1022 | margin: 0; 1023 | } 1024 | .pure-form .pure-group input:first-child:last-child, 1025 | .pure-form .pure-group textarea:first-child:last-child { 1026 | top: 1px; 1027 | border-radius: 4px; 1028 | margin: 0; 1029 | } 1030 | .pure-form .pure-group input:last-child, 1031 | .pure-form .pure-group textarea:last-child { 1032 | top: -2px; 1033 | border-radius: 0 0 4px 4px; 1034 | margin: 0; 1035 | } 1036 | .pure-form .pure-group button { 1037 | margin: 0.35em 0; 1038 | } 1039 | 1040 | .pure-form .pure-input-1 { 1041 | width: 100%; 1042 | } 1043 | .pure-form .pure-input-3-4 { 1044 | width: 75%; 1045 | } 1046 | .pure-form .pure-input-2-3 { 1047 | width: 66%; 1048 | } 1049 | .pure-form .pure-input-1-2 { 1050 | width: 50%; 1051 | } 1052 | .pure-form .pure-input-1-3 { 1053 | width: 33%; 1054 | } 1055 | .pure-form .pure-input-1-4 { 1056 | width: 25%; 1057 | } 1058 | 1059 | /* Inline help for forms */ 1060 | .pure-form-message-inline { 1061 | display: inline-block; 1062 | padding-left: 0.3em; 1063 | color: #666; 1064 | vertical-align: middle; 1065 | font-size: 0.875em; 1066 | } 1067 | 1068 | /* Block help for forms */ 1069 | .pure-form-message { 1070 | display: block; 1071 | color: #666; 1072 | font-size: 0.875em; 1073 | } 1074 | 1075 | @media only screen and (max-width : 480px) { 1076 | .pure-form button[type="submit"] { 1077 | margin: 0.7em 0 0; 1078 | } 1079 | 1080 | .pure-form input:not([type]), 1081 | .pure-form input[type="text"], 1082 | .pure-form input[type="password"], 1083 | .pure-form input[type="email"], 1084 | .pure-form input[type="url"], 1085 | .pure-form input[type="date"], 1086 | .pure-form input[type="month"], 1087 | .pure-form input[type="time"], 1088 | .pure-form input[type="datetime"], 1089 | .pure-form input[type="datetime-local"], 1090 | .pure-form input[type="week"], 1091 | .pure-form input[type="number"], 1092 | .pure-form input[type="search"], 1093 | .pure-form input[type="tel"], 1094 | .pure-form input[type="color"], 1095 | .pure-form label { 1096 | margin-bottom: 0.3em; 1097 | display: block; 1098 | } 1099 | 1100 | .pure-group input:not([type]), 1101 | .pure-group input[type="text"], 1102 | .pure-group input[type="password"], 1103 | .pure-group input[type="email"], 1104 | .pure-group input[type="url"], 1105 | .pure-group input[type="date"], 1106 | .pure-group input[type="month"], 1107 | .pure-group input[type="time"], 1108 | .pure-group input[type="datetime"], 1109 | .pure-group input[type="datetime-local"], 1110 | .pure-group input[type="week"], 1111 | .pure-group input[type="number"], 1112 | .pure-group input[type="search"], 1113 | .pure-group input[type="tel"], 1114 | .pure-group input[type="color"] { 1115 | margin-bottom: 0; 1116 | } 1117 | 1118 | .pure-form-aligned .pure-control-group label { 1119 | margin-bottom: 0.3em; 1120 | text-align: left; 1121 | display: block; 1122 | width: 100%; 1123 | } 1124 | 1125 | .pure-form-aligned .pure-controls { 1126 | margin: 1.5em 0 0 0; 1127 | } 1128 | 1129 | .pure-form-message-inline, 1130 | .pure-form-message { 1131 | display: block; 1132 | font-size: 0.75em; 1133 | /* Increased bottom padding to make it group with its related input element. */ 1134 | padding: 0.2em 0 0.8em; 1135 | } 1136 | } 1137 | 1138 | /*csslint adjoining-classes: false, box-model:false*/ 1139 | .pure-menu { 1140 | -webkit-box-sizing: border-box; 1141 | box-sizing: border-box; 1142 | } 1143 | 1144 | .pure-menu-fixed { 1145 | position: fixed; 1146 | left: 0; 1147 | top: 0; 1148 | z-index: 3; 1149 | } 1150 | 1151 | .pure-menu-list, 1152 | .pure-menu-item { 1153 | position: relative; 1154 | } 1155 | 1156 | .pure-menu-list { 1157 | list-style: none; 1158 | margin: 0; 1159 | padding: 0; 1160 | } 1161 | 1162 | .pure-menu-item { 1163 | padding: 0; 1164 | margin: 0; 1165 | height: 100%; 1166 | } 1167 | 1168 | .pure-menu-link, 1169 | .pure-menu-heading { 1170 | display: block; 1171 | text-decoration: none; 1172 | white-space: nowrap; 1173 | } 1174 | 1175 | /* HORIZONTAL MENU */ 1176 | .pure-menu-horizontal { 1177 | width: 100%; 1178 | white-space: nowrap; 1179 | } 1180 | 1181 | .pure-menu-horizontal .pure-menu-list { 1182 | display: inline-block; 1183 | } 1184 | 1185 | /* Initial menus should be inline-block so that they are horizontal */ 1186 | .pure-menu-horizontal .pure-menu-item, 1187 | .pure-menu-horizontal .pure-menu-heading, 1188 | .pure-menu-horizontal .pure-menu-separator { 1189 | display: inline-block; 1190 | vertical-align: middle; 1191 | } 1192 | 1193 | /* Submenus should still be display: block; */ 1194 | .pure-menu-item .pure-menu-item { 1195 | display: block; 1196 | } 1197 | 1198 | .pure-menu-children { 1199 | display: none; 1200 | position: absolute; 1201 | left: 100%; 1202 | top: 0; 1203 | margin: 0; 1204 | padding: 0; 1205 | z-index: 3; 1206 | } 1207 | 1208 | .pure-menu-horizontal .pure-menu-children { 1209 | left: 0; 1210 | top: auto; 1211 | width: inherit; 1212 | } 1213 | 1214 | .pure-menu-allow-hover:hover > .pure-menu-children, 1215 | .pure-menu-active > .pure-menu-children { 1216 | display: block; 1217 | position: absolute; 1218 | } 1219 | 1220 | /* Vertical Menus - show the dropdown arrow */ 1221 | .pure-menu-has-children > .pure-menu-link:after { 1222 | padding-left: 0.5em; 1223 | content: "\25B8"; 1224 | font-size: small; 1225 | } 1226 | 1227 | /* Horizontal Menus - show the dropdown arrow */ 1228 | .pure-menu-horizontal .pure-menu-has-children > .pure-menu-link:after { 1229 | content: "\25BE"; 1230 | } 1231 | 1232 | /* scrollable menus */ 1233 | .pure-menu-scrollable { 1234 | overflow-y: scroll; 1235 | overflow-x: hidden; 1236 | } 1237 | 1238 | .pure-menu-scrollable .pure-menu-list { 1239 | display: block; 1240 | } 1241 | 1242 | .pure-menu-horizontal.pure-menu-scrollable .pure-menu-list { 1243 | display: inline-block; 1244 | } 1245 | 1246 | .pure-menu-horizontal.pure-menu-scrollable { 1247 | white-space: nowrap; 1248 | overflow-y: hidden; 1249 | overflow-x: auto; 1250 | /* a little extra padding for this style to allow for scrollbars */ 1251 | padding: .5em 0; 1252 | } 1253 | 1254 | /* misc default styling */ 1255 | 1256 | .pure-menu-separator, 1257 | .pure-menu-horizontal .pure-menu-children .pure-menu-separator { 1258 | background-color: #ccc; 1259 | height: 1px; 1260 | margin: .3em 0; 1261 | } 1262 | 1263 | .pure-menu-horizontal .pure-menu-separator { 1264 | width: 1px; 1265 | height: 1.3em; 1266 | margin: 0 .3em ; 1267 | } 1268 | 1269 | /* Need to reset the separator since submenu is vertical */ 1270 | .pure-menu-horizontal .pure-menu-children .pure-menu-separator { 1271 | display: block; 1272 | width: auto; 1273 | } 1274 | 1275 | .pure-menu-heading { 1276 | text-transform: uppercase; 1277 | color: #565d64; 1278 | } 1279 | 1280 | .pure-menu-link { 1281 | color: #777; 1282 | } 1283 | 1284 | .pure-menu-children { 1285 | background-color: #fff; 1286 | } 1287 | 1288 | .pure-menu-link, 1289 | .pure-menu-heading { 1290 | padding: .5em 1em; 1291 | } 1292 | 1293 | .pure-menu-disabled { 1294 | opacity: .5; 1295 | } 1296 | 1297 | .pure-menu-disabled .pure-menu-link:hover { 1298 | background-color: transparent; 1299 | cursor: default; 1300 | } 1301 | 1302 | .pure-menu-active > .pure-menu-link, 1303 | .pure-menu-link:hover, 1304 | .pure-menu-link:focus { 1305 | background-color: #eee; 1306 | } 1307 | 1308 | .pure-menu-selected > .pure-menu-link, 1309 | .pure-menu-selected > .pure-menu-link:visited { 1310 | color: #000; 1311 | } 1312 | 1313 | .pure-table { 1314 | /* Remove spacing between table cells (from Normalize.css) */ 1315 | border-collapse: collapse; 1316 | border-spacing: 0; 1317 | empty-cells: show; 1318 | border: 1px solid #cbcbcb; 1319 | } 1320 | 1321 | .pure-table caption { 1322 | color: #000; 1323 | font: italic 85%/1 arial, sans-serif; 1324 | padding: 1em 0; 1325 | text-align: center; 1326 | } 1327 | 1328 | .pure-table td, 1329 | .pure-table th { 1330 | border-left: 1px solid #cbcbcb;/* inner column border */ 1331 | border-width: 0 0 0 1px; 1332 | font-size: inherit; 1333 | margin: 0; 1334 | overflow: visible; /*to make ths where the title is really long work*/ 1335 | padding: 0.5em 1em; /* cell padding */ 1336 | } 1337 | 1338 | .pure-table thead { 1339 | background-color: #e0e0e0; 1340 | color: #000; 1341 | text-align: left; 1342 | vertical-align: bottom; 1343 | } 1344 | 1345 | /* 1346 | striping: 1347 | even - #fff (white) 1348 | odd - #f2f2f2 (light gray) 1349 | */ 1350 | .pure-table td { 1351 | background-color: transparent; 1352 | } 1353 | .pure-table-odd td { 1354 | background-color: #f2f2f2; 1355 | } 1356 | 1357 | /* nth-child selector for modern browsers */ 1358 | .pure-table-striped tr:nth-child(2n-1) td { 1359 | background-color: #f2f2f2; 1360 | } 1361 | 1362 | /* BORDERED TABLES */ 1363 | .pure-table-bordered td { 1364 | border-bottom: 1px solid #cbcbcb; 1365 | } 1366 | .pure-table-bordered tbody > tr:last-child > td { 1367 | border-bottom-width: 0; 1368 | } 1369 | 1370 | 1371 | /* HORIZONTAL BORDERED TABLES */ 1372 | 1373 | .pure-table-horizontal td, 1374 | .pure-table-horizontal th { 1375 | border-width: 0 0 1px 0; 1376 | border-bottom: 1px solid #cbcbcb; 1377 | } 1378 | .pure-table-horizontal tbody > tr:last-child > td { 1379 | border-bottom-width: 0; 1380 | } --------------------------------------------------------------------------------