diff --git a/.eslintrc.js b/.eslintrc.js index 6ebd4e25b..d3e0f4517 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,14 +1,40 @@ module.exports = { env: { browser: true, - jquery: true, - node: true, - es6: true, - "jest/globals": true, - }, - extends: ["eslint:recommended"], - ignorePatterns: ["**/dist"], - parser: "@babel/eslint-parser", - plugins: ["jest"], - rules: {}, + es2021: true, + }, + extends: ["eslint:recommended", "plugin:react/recommended"], + overrides: [ + { + env: { + node: true, + }, + files: [".eslintrc.{js,cjs}"], + parserOptions: { + sourceType: "script", + }, + }, + ], + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + }, + plugins: ["react"], + rules: { + "react/react-in-jsx-scope": "off", + "react/jsx-uses-react": "off", + // Temporarily turn off prop-types + "react/prop-types": "off", + "no-unused-vars": ["error", { args: "after-used" }], + }, + ignorePatterns: [ + "jupyterhub_fancy_profiles/static/*.js", + "webpack.config.js", + "babel.config.js", + ], + settings: { + react: { + version: "detect", + }, + }, }; diff --git a/babel.config.json b/babel.config.json index 1320b9a32..08d007ea3 100644 --- a/babel.config.json +++ b/babel.config.json @@ -1,3 +1,6 @@ { - "presets": ["@babel/preset-env"] + "presets": [ + "@babel/preset-env", + ["@babel/preset-react", { "runtime": "automatic" }] + ] } diff --git a/binderhub/app.py b/binderhub/app.py index 2cf9e2b50..2b5e34484 100644 --- a/binderhub/app.py +++ b/binderhub/app.py @@ -42,15 +42,15 @@ ) from traitlets.config import Application -from .base import AboutHandler, Custom404, VersionHandler +from .base import Custom404, VersionHandler from .build import BuildExecutor, KubernetesBuildExecutor, KubernetesCleaner from .builder import BuildHandler -from .config import ConfigHandler from .events import EventLog +from .handlers.repoproviders import RepoProvidersHandlers from .health import HealthHandler, KubernetesHealthHandler from .launcher import Launcher from .log import log_request -from .main import LegacyRedirectHandler, MainHandler, ParameterizedMainHandler +from .main import LegacyRedirectHandler, RepoLaunchUIHandler, UIHandler from .metrics import MetricsHandler from .quota import KubernetesLaunchQuota, LaunchQuota from .ratelimit import RateLimiter @@ -107,6 +107,11 @@ def _log_level(self): None, allow_none=True, help=""" + ..deprecated:: + + No longer supported. If you want to use Google Analytics, use :attr:`extra_footer_scripts` + to load JS from Google Analytics. + The Google Analytics code to use on the main page. Note that we'll respect Do Not Track settings, despite the fact that GA does not. @@ -118,6 +123,11 @@ def _log_level(self): google_analytics_domain = Unicode( "auto", help=""" + ..deprecated:: + + No longer supported. If you want to use Google Analytics, use :attr:`extra_footer_scripts` + to load JS from Google Analytics. + The Google Analytics domain to use on the main page. By default this is set to 'auto', which sets it up for current domain and all @@ -126,6 +136,13 @@ def _log_level(self): config=True, ) + @observe("google_analytics_domain", "google_analytics_code") + def _google_analytics_deprecation(self, change): + if change.new: + raise ValueError( + f"Setting {change.owner.__class__.__name__}.{change.name} is no longer supported. Use {change.owner.__class__.__name__}.extra_footer_scripts to load Google Analytics JS directly" + ) + about_message = Unicode( "", help=""" @@ -798,7 +815,6 @@ def _template_path_default(self): - /versions - /build/([^/]+)/(.+) - /health - - /_config - /* -> shows a 404 page """, config=True, @@ -945,8 +961,6 @@ def initialize(self, *args, **kwargs): "registry": registry, "traitlets_config": self.config, "traitlets_parent": self, - "google_analytics_code": self.google_analytics_code, - "google_analytics_domain": self.google_analytics_domain, "about_message": self.about_message, "banner_message": self.banner_message, "extra_footer_scripts": self.extra_footer_scripts, @@ -975,15 +989,23 @@ def initialize(self, *args, **kwargs): (r"/versions", VersionHandler), (r"/build/([^/]+)/(.+)", BuildHandler), (r"/health", self.health_handler_class, {"hub_url": self.hub_url_local}), - (r"/_config", ConfigHandler), + (r"/api/repoproviders", RepoProvidersHandlers), ] if not self.enable_api_only_mode: # In API only mode the endpoints in the list below - # are unregistered as they don't make sense in a API only scenario + # are not registered since they are primarily about providing UI + + for provider_id in self.repo_providers: + # Register launchable URLs for all our repo providers + # These render social previews, but otherwise redirect to UIHandler + handlers += [ + ( + rf"/v2/({provider_id})/(.+)", + RepoLaunchUIHandler, + {"repo_provider": self.repo_providers[provider_id]}, + ) + ] handlers += [ - (r"/about", AboutHandler), - (r"/v2/([^/]+)/(.+)", ParameterizedMainHandler), - (r"/", MainHandler), (r"/repo/([^/]+)/([^/]+)(/.*)?", LegacyRedirectHandler), # for backward-compatible mybinder.org badge URLs # /assets/images/badge.svg @@ -1050,6 +1072,7 @@ def initialize(self, *args, **kwargs): ) }, ), + (r"/.*", UIHandler), ] # This needs to be the last handler in the list, because it needs to match "everything else" handlers.append((r".*", Custom404)) diff --git a/binderhub/base.py b/binderhub/base.py index 5f198c401..51adc1fe4 100644 --- a/binderhub/base.py +++ b/binderhub/base.py @@ -229,22 +229,6 @@ def prepare(self): raise web.HTTPError(404) -class AboutHandler(BaseHandler): - """Serve the about page""" - - async def get(self): - self.render_template( - "about.html", - base_url=self.settings["base_url"], - submit=False, - binder_version=binder_version, - message=self.settings["about_message"], - google_analytics_code=self.settings["google_analytics_code"], - google_analytics_domain=self.settings["google_analytics_domain"], - extra_footer_scripts=self.settings["extra_footer_scripts"], - ) - - class VersionHandler(BaseHandler): """Serve information about versions running""" diff --git a/binderhub/handlers/__init__.py b/binderhub/handlers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/binderhub/handlers/repoproviders.py b/binderhub/handlers/repoproviders.py new file mode 100644 index 000000000..7bfb3c94f --- /dev/null +++ b/binderhub/handlers/repoproviders.py @@ -0,0 +1,15 @@ +import json + +from ..base import BaseHandler + + +class RepoProvidersHandlers(BaseHandler): + """Serve config""" + + async def get(self): + config = [ + repo_provider_class.display_config + for repo_provider_class in self.settings["repo_providers"].values() + ] + self.set_header("Content-type", "application/json") + self.write(json.dumps(config)) diff --git a/binderhub/main.py b/binderhub/main.py index f89d23d79..7d66c5303 100644 --- a/binderhub/main.py +++ b/binderhub/main.py @@ -2,130 +2,66 @@ Main handler classes for requests """ -import time -import urllib.parse - -import jwt -from tornado.httpclient import AsyncHTTPClient, HTTPRequest from tornado.httputil import url_concat -from tornado.log import app_log -from tornado.web import HTTPError, authenticated +from tornado.web import authenticated +from . import __version__ as binder_version from .base import BaseHandler -SPEC_NAMES = { - "gh": "GitHub", - "gist": "Gist", - "gl": "GitLab", - "git": "Git repo", - "zenodo": "Zenodo", - "figshare": "Figshare", - "hydroshare": "Hydroshare", - "dataverse": "Dataverse", - "ckan": "CKAN", -} +class UIHandler(BaseHandler): + """ + Responds to most UI Page Requests + """ -class MainHandler(BaseHandler): - """Main handler for requests""" + def initialize(self): + self.opengraph_title = "The Binder Project" + return super().initialize() @authenticated def get(self): + repoproviders_display_config = [ + repo_provider_class.display_config + for repo_provider_class in self.settings["repo_providers"].values() + ] + page_config = { + "baseUrl": self.settings["base_url"], + "badgeBaseUrl": self.get_badge_base_url(), + "logoUrl": self.static_url("logo.svg"), + "logoWidth": "320px", + "repoProviders": repoproviders_display_config, + "aboutMessage": self.settings["about_message"], + "bannerHtml": self.settings["banner_message"], + "binderVersion": binder_version, + } self.render_template( - "index.html", - badge_base_url=self.get_badge_base_url(), - base_url=self.settings["base_url"], - submit=False, - google_analytics_code=self.settings["google_analytics_code"], - google_analytics_domain=self.settings["google_analytics_domain"], + "page.html", + page_config=page_config, extra_footer_scripts=self.settings["extra_footer_scripts"], - repo_providers=self.settings["repo_providers"], + opengraph_title=self.opengraph_title, ) -class ParameterizedMainHandler(BaseHandler): - """Main handler that allows different parameter settings""" - - @authenticated - async def get(self, provider_prefix, _unescaped_spec): - prefix = "/v2/" + provider_prefix - spec = self.get_spec_from_request(prefix) - spec = spec.rstrip("/") - try: - self.get_provider(provider_prefix, spec=spec) - except HTTPError: - raise - except Exception as e: - app_log.error( - "Failed to construct provider for %s/%s", - provider_prefix, - spec, - ) - # FIXME: 400 assumes it's the user's fault (?) - # maybe we should catch a special InvalidSpecError here - raise HTTPError(400, str(e)) - - provider_spec = f"{provider_prefix}/{spec}" - social_desc = f"{SPEC_NAMES[provider_prefix]}: {spec}" - nbviewer_url = None - if provider_prefix == "gh": - # We can only produce an nbviewer URL for github right now - nbviewer_url = "https://nbviewer.jupyter.org/github" - org, repo_name, ref = spec.split("/", 2) - # NOTE: tornado unquotes query arguments too -> notebooks%2Findex.ipynb becomes notebooks/index.ipynb - filepath = self.get_argument("labpath", "").lstrip("/") - if not filepath: - filepath = self.get_argument("filepath", "").lstrip("/") - - # Check the urlpath parameter for a file path, if so use it for the filepath - urlpath = self.get_argument("urlpath", "").lstrip("/") - if urlpath and "/tree/" in urlpath: - filepath = urlpath.split("tree/", 1)[-1] +class RepoLaunchUIHandler(UIHandler): + """ + Responds to /v2/ launch URLs only - blob_or_tree = "blob" if filepath else "tree" - nbviewer_url = ( - f"{nbviewer_url}/{org}/{repo_name}/{blob_or_tree}/{ref}/{filepath}" - ) + Forwards to UIHandler, but puts out an opengraph_title for social previews + """ - # Check if the nbviewer URL is valid and would display something - # useful to the reader, if not we don't show it - client = AsyncHTTPClient() - # quote any unicode characters in the URL - proto, rest = nbviewer_url.split("://") - rest = urllib.parse.quote(rest) + def initialize(self, repo_provider): + self.repo_provider = repo_provider + return super().initialize() - request = HTTPRequest( - proto + "://" + rest, - method="HEAD", - user_agent="BinderHub", - ) - response = await client.fetch(request, raise_error=False) - if response.code >= 400: - nbviewer_url = None + @authenticated + def get(self, provider_id, _escaped_spec): + prefix = "/v2/" + provider_id + spec = self.get_spec_from_request(prefix).rstrip("/") - build_token = jwt.encode( - { - "exp": int(time.time()) + self.settings["build_token_expires_seconds"], - "aud": provider_spec, - "origin": self.token_origin(), - }, - key=self.settings["build_token_secret"], - algorithm="HS256", - ) - self.render_template( - "loading.html", - base_url=self.settings["base_url"], - badge_base_url=self.get_badge_base_url(), - build_token=build_token, - provider_spec=provider_spec, - social_desc=social_desc, - nbviewer_url=nbviewer_url, - # urlpath=self.get_argument('urlpath', None), - submit=True, - google_analytics_code=self.settings["google_analytics_code"], - google_analytics_domain=self.settings["google_analytics_domain"], - extra_footer_scripts=self.settings["extra_footer_scripts"], + self.opengraph_title = ( + f"{self.repo_provider.display_config['displayName']}: {spec}" ) + return super().get() class LegacyRedirectHandler(BaseHandler): diff --git a/binderhub/repoproviders.py b/binderhub/repoproviders.py index 6e4a0af96..b91fde2e3 100644 --- a/binderhub/repoproviders.py +++ b/binderhub/repoproviders.py @@ -120,6 +120,8 @@ class RepoProvider(LoggingConfigurable): unresolved_ref = Unicode() + display_config = {} + git_credentials = Unicode( "", help=""" @@ -220,11 +222,17 @@ def is_valid_sha1(sha1): class FakeProvider(RepoProvider): """Fake provider for local testing of the UI""" - labels = { - "text": "Fake Provider", - "tag_text": "Fake Ref", - "ref_prop_disabled": True, - "label_prop_disabled": True, + display_config = { + "displayName": "Fake", + "id": "fake", + "enabled": False, + "repo": { + "label": "Fake Repo", + "placeholder": "", + }, + "ref": { + "enabled": False, + }, } async def get_resolved_ref(self): @@ -251,15 +259,18 @@ class ZenodoProvider(RepoProvider): name = Unicode("Zenodo") - display_name = "Zenodo DOI" - - labels = { - "text": "Zenodo DOI (10.5281/zenodo.3242074)", - "tag_text": "Git ref (branch, tag, or commit)", - "ref_prop_disabled": True, - "label_prop_disabled": True, + display_config = { + "displayName": "Zenodo DOI", + "id": "zenodo", + "repo": { + "label": "Zenodo DOI", + "placeholder": "example: 10.5281/zenodo.3242074", + }, + "ref": {"enabled": False}, } + display_name = "Zenodo DOI" + async def get_resolved_ref(self): client = AsyncHTTPClient() req = HTTPRequest(f"https://doi.org/{self.spec}", user_agent="BinderHub") @@ -300,15 +311,18 @@ class FigshareProvider(RepoProvider): display_name = "Figshare DOI" - url_regex = re.compile(r"(.*)/articles/([^/]+)/([^/]+)/(\d+)(/)?(\d+)?") - - labels = { - "text": "Figshare DOI (10.6084/m9.figshare.9782777.v1)", - "tag_text": "Git ref (branch, tag, or commit)", - "ref_prop_disabled": True, - "label_prop_disabled": True, + display_config = { + "displayName": "FigShare DOI", + "id": "figshare", + "repo": { + "label": "FigShare DOI", + "placeholder": "example: 10.6084/m9.figshare.9782777.v1", + }, + "ref": {"enabled": False}, } + url_regex = re.compile(r"(.*)/articles/([^/]+)/([^/]+)/(\d+)(/)?(\d+)?") + async def get_resolved_ref(self): client = AsyncHTTPClient() req = HTTPRequest(f"https://doi.org/{self.spec}", user_agent="BinderHub") @@ -351,11 +365,11 @@ class DataverseProvider(RepoProvider): display_name = "Dataverse DOI" - labels = { - "text": "Dataverse DOI (10.7910/DVN/TJCLKP)", - "tag_text": "Git ref (branch, tag, or commit)", - "ref_prop_disabled": True, - "label_prop_disabled": True, + display_config = { + "displayName": "Dataverse DOI", + "id": "dataverse", + "repo": {"label": "FigShare DOI", "placeholder": "example: 10.7910/DVN/TJCLKP"}, + "ref": {"enabled": False}, } async def get_resolved_ref(self): @@ -418,15 +432,15 @@ class HydroshareProvider(RepoProvider): display_name = "Hydroshare resource" - url_regex = re.compile(r".*([0-9a-f]{32}).*") - - labels = { - "text": "Hydroshare resource id or URL", - "tag_text": "Git ref (branch, tag, or commit)", - "ref_prop_disabled": True, - "label_prop_disabled": True, + display_config = { + "displayName": "Hydroshare resource", + "id": "hydroshare", + "repo": {"label": "Hydroshare resource id or URL", "placeholder": ""}, + "ref": {"enabled": False}, } + url_regex = re.compile(r".*([0-9a-f]{32}).*") + def _parse_resource_id(self, spec): match = self.url_regex.match(spec) if not match: @@ -484,11 +498,14 @@ class CKANProvider(RepoProvider): display_name = "CKAN dataset" - labels = { - "text": "CKAN dataset URL (https://demo.ckan.org/dataset/sample-dataset-1)", - "tag_text": "Git ref (branch, tag, or commit)", - "ref_prop_disabled": True, - "label_prop_disabled": True, + display_config = { + "displayName": "CKAN dataset", + "id": "ckan", + "repo": { + "label": "CKAN dataset URL", + "placeholder": "https://demo.ckan.org/dataset/sample-dataset-1", + }, + "ref": {"enabled": False}, } def __init__(self, *args, **kwargs): @@ -581,11 +598,14 @@ class GitRepoProvider(RepoProvider): display_name = "Git repository" - labels = { - "text": "Arbitrary git repository URL (http://git.example.com/repo)", - "tag_text": "Git ref (branch, tag, or commit)", - "ref_prop_disabled": False, - "label_prop_disabled": False, + display_config = { + "displayName": "Git repository", + "id": "git", + "repo": { + "label": "Arbitrary git repository URL", + "placeholder": "example: http://git.example.com/repo", + }, + "ref": {"enabled": True, "default": "HEAD"}, } allowed_protocols = Set( @@ -685,6 +705,17 @@ class GitLabRepoProvider(RepoProvider): display_name = "GitLab.com" + display_config = { + "displayName": "GitLab", + "id": "gl", + "detect": {"regex": "^(https?://gitlab.com/)?(?.*)"}, + "repo": { + "label": "GitLab repository name or URL", + "placeholder": "example: https://gitlab.com/mosaik/examples/mosaik-tutorials-on-binder or mosaik/examples/mosaik-tutorials-on-binder", + }, + "ref": {"enabled": True, "default": "HEAD"}, + } + hostname = Unicode( "gitlab.com", config=True, @@ -741,13 +772,6 @@ def _default_git_credentials(self): return rf"username=binderhub\npassword={self.private_token}" return "" - labels = { - "text": "GitLab.com repository or URL", - "tag_text": "Git ref (branch, tag, or commit)", - "ref_prop_disabled": False, - "label_prop_disabled": False, - } - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.quoted_namespace, unresolved_ref = self.spec.split("/", 1) @@ -808,6 +832,17 @@ class GitHubRepoProvider(RepoProvider): name = Unicode("GitHub") + display_config = { + "displayName": "GitHub", + "id": "gh", + "detect": {"regex": "^(https?://github.com/)?(?.*)"}, + "repo": { + "label": "GitHub repository name or URL", + "placeholder": "example: yuvipanda/requirements or https://github.com/yuvipanda/requirements", + }, + "ref": {"enabled": True, "default": "HEAD"}, + } + display_name = "GitHub" # shared cache for resolved refs @@ -894,13 +929,6 @@ def _default_git_credentials(self): return rf"username={self.access_token}\npassword=x-oauth-basic" return "" - labels = { - "text": "GitHub repository name or URL", - "tag_text": "Git ref (branch, tag, or commit)", - "ref_prop_disabled": False, - "label_prop_disabled": False, - } - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.user, self.repo, self.unresolved_ref = tokenize_spec(self.spec) @@ -1079,6 +1107,14 @@ class GistRepoProvider(GitHubRepoProvider): display_name = "Gist" + display_config = { + "displayName": "Gist", + "id": "gist", + "detect": {"regex": "^(https?://gist.github.com/)?(?.*)"}, + "repo": {"label": "Gist ID (username/gistId) or URL", "placeholder": ""}, + "ref": {"enabled": True, "default": "HEAD"}, + } + hostname = Unicode("gist.github.com") allow_secret_gist = Bool( @@ -1087,13 +1123,6 @@ class GistRepoProvider(GitHubRepoProvider): help="Flag for allowing usages of secret Gists. The default behavior is to disallow secret gists.", ) - labels = { - "text": "Gist ID (username/gistId) or URL", - "tag_text": "Git commit SHA", - "ref_prop_disabled": False, - "label_prop_disabled": False, - } - def __init__(self, *args, **kwargs): # We dont need to initialize entirely the same as github super(RepoProvider, self).__init__(*args, **kwargs) diff --git a/binderhub/static/fonts/clearsans/LICENSE-2.0.txt b/binderhub/static/fonts/clearsans/LICENSE-2.0.txt deleted file mode 100644 index d64569567..000000000 --- a/binderhub/static/fonts/clearsans/LICENSE-2.0.txt +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Bold.woff b/binderhub/static/fonts/clearsans/WOFF/ClearSans-Bold.woff deleted file mode 100644 index bda6eb27a..000000000 Binary files a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Bold.woff and /dev/null differ diff --git a/binderhub/static/fonts/clearsans/WOFF/ClearSans-BoldItalic.woff b/binderhub/static/fonts/clearsans/WOFF/ClearSans-BoldItalic.woff deleted file mode 100644 index 4dee91c29..000000000 Binary files a/binderhub/static/fonts/clearsans/WOFF/ClearSans-BoldItalic.woff and /dev/null differ diff --git a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Italic.woff b/binderhub/static/fonts/clearsans/WOFF/ClearSans-Italic.woff deleted file mode 100644 index 56573d21d..000000000 Binary files a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Italic.woff and /dev/null differ diff --git a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Light.woff b/binderhub/static/fonts/clearsans/WOFF/ClearSans-Light.woff deleted file mode 100644 index bae448bfa..000000000 Binary files a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Light.woff and /dev/null differ diff --git a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Medium.woff b/binderhub/static/fonts/clearsans/WOFF/ClearSans-Medium.woff deleted file mode 100644 index 702fb8aed..000000000 Binary files a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Medium.woff and /dev/null differ diff --git a/binderhub/static/fonts/clearsans/WOFF/ClearSans-MediumItalic.woff b/binderhub/static/fonts/clearsans/WOFF/ClearSans-MediumItalic.woff deleted file mode 100644 index aecf7dfbc..000000000 Binary files a/binderhub/static/fonts/clearsans/WOFF/ClearSans-MediumItalic.woff and /dev/null differ diff --git a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Regular.woff b/binderhub/static/fonts/clearsans/WOFF/ClearSans-Regular.woff deleted file mode 100644 index f4aacf79d..000000000 Binary files a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Regular.woff and /dev/null differ diff --git a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Thin.woff b/binderhub/static/fonts/clearsans/WOFF/ClearSans-Thin.woff deleted file mode 100644 index aa501c6bc..000000000 Binary files a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Thin.woff and /dev/null differ diff --git a/binderhub/static/images/caretdown-white.svg b/binderhub/static/images/caretdown-white.svg deleted file mode 100644 index 731fc2f3e..000000000 --- a/binderhub/static/images/caretdown-white.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/binderhub/static/images/clipboard.svg b/binderhub/static/images/clipboard.svg deleted file mode 100644 index 80c2cb91a..000000000 --- a/binderhub/static/images/clipboard.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/binderhub/static/images/copy-icon-black.svg b/binderhub/static/images/copy-icon-black.svg deleted file mode 100644 index a15cf49c6..000000000 --- a/binderhub/static/images/copy-icon-black.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/binderhub/static/images/copy-icon-white.svg b/binderhub/static/images/copy-icon-white.svg deleted file mode 100644 index db97266b1..000000000 --- a/binderhub/static/images/copy-icon-white.svg +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/binderhub/static/images/favicon_fail.ico b/binderhub/static/images/favicon/fail.ico similarity index 100% rename from binderhub/static/images/favicon_fail.ico rename to binderhub/static/images/favicon/fail.ico diff --git a/binderhub/static/images/favicon_building.ico b/binderhub/static/images/favicon/progress.ico similarity index 100% rename from binderhub/static/images/favicon_building.ico rename to binderhub/static/images/favicon/progress.ico diff --git a/binderhub/static/images/favicon_success.ico b/binderhub/static/images/favicon/success.ico similarity index 100% rename from binderhub/static/images/favicon_success.ico rename to binderhub/static/images/favicon/success.ico diff --git a/binderhub/static/index.css b/binderhub/static/index.css deleted file mode 100644 index 31021457f..000000000 --- a/binderhub/static/index.css +++ /dev/null @@ -1,314 +0,0 @@ -/* custom fonts we are going to be using. */ - -@font-face { - font-family: ClearSans-Thin; - src: url("../static/fonts/clearsans/WOFF/ClearSans-Thin.woff"); -} - -@font-face { - font-family: ClearSans-Light; - src: url("../static/fonts/clearsans/WOFF/ClearSans-Light.woff"); -} - -@font-face { - font-family: ClearSans-Bold; - src: url("../static/fonts/clearsans/WOFF/ClearSans-Bold.woff"); -} - -.hidden { - display: none; -} - -body { - font-family: "ClearSans-Thin", sans-serif; -} - -form { - font-family: "ClearSans-Light", sans-serif; -} - -p > a { - cursor: pointer; - color: rgb(120, 120, 120); - border-bottom: dotted 2px rgb(120, 120, 120); - transition: all 0.1s; - text-decoration: non; -} - -p > a:hover { - color: rgb(30, 30, 30); - border-bottom: dotted 2px rgb(30, 30, 30); - text-decoration: none; -} - -#build-form { - color: rgb(50, 50, 50); - background: rgb(235, 236, 237); - padding: 55px; - padding-top: 25px; - padding-bottom: 20px; -} - -#banner-container { - text-align: left; - color: black; - padding: 16px; - width: 100%; - background-color: rgb(235, 236, 237); - position: relative; -} - -#logo-container { - text-align: center; - color: black; - padding: 16px; -} - -#logo { - padding: 8px; - padding-bottom: 22px; - padding-top: 10px; -} - -#header { - margin-left: 5%; - width: 90%; - padding-bottom: 24px; -} - -.btn-submit { - background-color: rgb(223, 132, 41); - box-shadow: 1px 1px rgba(0, 0, 0, 0.075); - color: white; - border: none; - height: 35px; - width: 100%; - border-radius: 4px; -} - -.jumbotron { - background: rgb(235, 236, 237); -} - -h3 { - color: rgb(70, 70, 70); - font-size: 42px; - line-height: 1.3; -} - -h4 { - font-size: 20px; - color: rgb(70, 70, 70); -} - -#form-header { - padding-bottom: 5px; -} - -#build-progress { - font-family: ClearSans-Bold, sans-serif; - font-size: 16px; - height: 28px; - text-shadow: black 1px 1px 1px; -} - -#build-progress .progress-bar { - padding-top: 4px; -} - -#explanation { - color: rgb(40, 40, 40); - font-size: 20px; - line-height: 1.5; - font-weight: bold; -} - -#log-container .panel-body { - /* match color of terminal! */ - background-color: black; -} - -#launch-buttons { - margin-top: 24px; - width: 100%; -} - -#log { - height: 400px; -} - -#log .terminal { - font-family: "Roboto Mono", monospace; -} - -.url, -.badges { - background-color: #ffffff; - -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); - border: 1px solid #ccc; - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); - border-radius: 4px; - display: flex; - flex-direction: column; - align-items: flex-end; -} - -.url { - margin-bottom: 20px; -} - -.badges { - margin-bottom: 40px; -} - -.dropdownmenu { - background-color: #dddddd; - border-radius: 3px 3px 0px 0px; - height: 35px; - width: 100%; -} - -.dropdownmenu label { - color: black; - padding: 6px 12px; - margin: 0px; - width: 95%; -} - -.badge-snippet-row, -.url-row { - width: 100%; - display: flex; - flex-direction: reverse; - border-bottom: 1px #ccc solid; - padding: 10px; -} - -.badge-snippet-row .icon, -.url-row .icon { - order: 0; - max-width: 30px; - max-height: 40px; - padding: 3px; - /* margin-top: 13px; */ - /*margin-left: 4px; */ -} - -.input-group-btn .btn { - border: solid #ccc 1px; -} - -.badge-snippet-row pre, -.url-row pre { - order: 1; - margin: 0; - flex-grow: 1; - border-top-right-radius: 0; - border-bottom-right-radius: 0; -} - -#how-it-works { - line-height: 1.5; - font-weight: bold; - font-size: 18px; -} - -#how-it-works div.row { - margin: 32px; - display: flex; - flex-direction: row; - align-items: baseline; -} - -.point { - border-radius: 50%; - border-width: 5px; - border-style: solid; - width: 40px; - height: 40px; - padding: 2px 9px; - font-weight: 800; - font-family: "ClearSans-Bold"; -} - -.point-container { - padding-top: 4px; -} - -.point-orange { - border-color: rgb(247, 144, 42); - color: rgb(247, 144, 42); -} - -.point-red { - border-color: rgb(204, 67, 101); - color: rgb(204, 67, 101); -} - -.point-blue { - border-color: rgb(41, 124, 184); - color: rgb(41, 124, 184); -} - -/*reduce font size of h1 and h2 so the initial design when h3 and h4 tags were used respectively is retained*/ -h1 { - font-size: 1.25em; -} - -h2 { - font-size: 1.125em; -} - -span.front-em { - font-size: 1.5em; -} - -div.front { - font-size: 0.9em; -} - -h4.logo-subtext { - margin-top: -60px; -} - -.form-row .form-group:first-child { - padding-left: 0; -} - -.form-row .form-group:last-child { - padding-right: 0; -} - -#badge-snippets { - width: 100%; -} - -/**/ - -@media (max-width: 991px) { - .form-row .form-group { - padding: 0; - } - - #launch-buttons { - margin-top: inherit; - } -} - -/*Clipboard styling*/ - -img.icon.clipboard { - order: 1; - border: thin solid silver; - border-radius: 5px; - border-bottom-left-radius: 0; - border-top-left-radius: 0; - border-left: none; -} -img.icon.clipboard:hover { - background: #f5f5f5; -} - -img.icon.clipboard:active { - background: #ddd; -} diff --git a/binderhub/static/js/App.jsx b/binderhub/static/js/App.jsx new file mode 100644 index 000000000..14428e169 --- /dev/null +++ b/binderhub/static/js/App.jsx @@ -0,0 +1,103 @@ +import { createRoot } from "react-dom/client"; + +import { LoadingPage } from "./pages/LoadingPage.jsx"; +import { createBrowserRouter, RouterProvider, Route } from "react-router-dom"; +import "bootstrap/js/dist/dropdown.js"; + +import "./index.scss"; +import "@fontsource/clear-sans/100.css"; +import "@fontsource/clear-sans/300.css"; +import "@fontsource/clear-sans/400.css"; +import { HomePage } from "./pages/HomePage.jsx"; +import { createRoutesFromElements } from "react-router"; +import { AboutPage } from "./pages/AboutPage.jsx"; + +export const PAGE_CONFIG = window.pageConfig; + +/** + * @typedef {object} RepoConfig + * @prop {string} label + * @prop {string} placeholder + * + * @typedef {object} DetectConfig + * @prop {string} regex + * + * @typedef {object} RefConfig + * @prop {boolean} enabled + * @prop {string} [default] + * + * @typedef {object} Provider + * @prop {string} displayName + * @prop {string} id + * @prop {DetectConfig} [detect] + * @prop {RepoConfig} repo + * @prop {RefConfig} ref + * + */ +/** + * @type {Array} + */ +export const PROVIDERS = PAGE_CONFIG.repoProviders; + +export const BASE_URL = new URL(PAGE_CONFIG.baseUrl, window.location.href); + +export const PUBLIC_BASE_URL = PAGE_CONFIG.publicBaseUrl + ? new URL(BASE_URL) + : new URL(PAGE_CONFIG.baseUrl, window.location.href); + +const router = createBrowserRouter( + createRoutesFromElements( + + + } + /> + {PROVIDERS.map((p) => ( + } + /> + ))} + + } + /> + , + ), +); +function App() { + return ( + <> + {PAGE_CONFIG.bannerHtml && ( +
+ )} +
+
+
+ +
+ +
+
+ + ); +} + +const root = createRoot(document.getElementById("root")); +root.render(); diff --git a/binderhub/static/js/components/BuilderLauncher.jsx b/binderhub/static/js/components/BuilderLauncher.jsx new file mode 100644 index 000000000..73718662e --- /dev/null +++ b/binderhub/static/js/components/BuilderLauncher.jsx @@ -0,0 +1,227 @@ +import { BinderRepository } from "@jupyterhub/binderhub-client"; +import { useEffect, useRef, useState } from "react"; +import { Terminal } from "xterm"; +import { FitAddon } from "xterm-addon-fit"; +import "xterm/css/xterm.css"; +import { Progress, PROGRESS_STATES } from "./Progress.jsx"; +import { Spec } from "../spec.js"; + +/** + * + * @param {URL} baseUrl + * @param {Spec} spec + * @param {Terminal} term + * @param {Array} logBuffer + * @param {FitAddon} fitAddon + * @param {(l: boolean) => void} setIsLaunching + * @param {(p: PROGRESS_STATES) => void} setProgressState + * @param {(e: boolean) => void} setEnsureLogsVisible + */ +async function buildImage( + baseUrl, + spec, + term, + logBuffer, + fitAddon, + setIsLaunching, + setProgressState, + setEnsureLogsVisible, +) { + const buildEndPointURL = new URL("build/", baseUrl); + const image = new BinderRepository(spec.buildSpec, buildEndPointURL); + // Clear the last line written, so we start from scratch + term.write("\x1b[2K\r"); + logBuffer.length = 0; + fitAddon.fit(); + for await (const data of image.fetch()) { + // Write message to the log terminal if there is a message + if (data.message !== undefined) { + // Write out all messages to the terminal! + term.write(data.message); + // Keep a copy of the message in the logBuffer + logBuffer.push(data.message); + // Resize our terminal to make sure it fits messages appropriately + fitAddon.fit(); + } else { + console.log(data); + } + + switch (data.phase) { + case "failed": { + image.close(); + setIsLaunching(false); + setProgressState(PROGRESS_STATES.FAILED); + setEnsureLogsVisible(true); + break; + } + case "ready": { + setProgressState(PROGRESS_STATES.SUCCESS); + image.close(); + const serverUrl = new URL(data.url); + window.location.href = spec.launchSpec.getJupyterServerRedirectUrl( + serverUrl, + data.token, + ); + console.log(data); + break; + } + case "building": { + setProgressState(PROGRESS_STATES.BUILDING); + break; + } + case "waiting": { + setProgressState(PROGRESS_STATES.WAITING); + break; + } + case "pushing": { + setProgressState(PROGRESS_STATES.PUSHING); + break; + } + case "built": { + setProgressState(PROGRESS_STATES.PUSHING); + break; + } + case "launching": { + setProgressState(PROGRESS_STATES.LAUNCHING); + break; + } + default: { + console.log("Unknown phase in response from server"); + console.log(data); + break; + } + } + } +} + +/** + * @typedef {object} ImageLogsProps + * @prop {(t: Terminal) => void} setTerm + * @prop {(f: FitAddon) => void} setFitAddon + * @prop {boolean} logsVisible + * @prop {Ref>} logBufferRef + * @prop {(l: boolean) => void} setLogsVisible + * + * @param {ImageLogsProps} props + * @returns + */ +function ImageLogs({ + setTerm, + setFitAddon, + logsVisible, + setLogsVisible, + logBufferRef, +}) { + const toggleLogsButton = useRef(); + useEffect(() => { + async function setup() { + const term = new Terminal({ + convertEol: true, + disableStdin: true, + }); + const fitAddon = new FitAddon(); + term.loadAddon(fitAddon); + term.open(document.getElementById("terminal")); + fitAddon.fit(); + setTerm(term); + setFitAddon(fitAddon); + term.write("Logs will appear here when image is being built"); + } + setup(); + }, []); + + return ( +
+
+ Build Logs + + +
+
+
+
+
+ ); +} + +/** + * @typedef {object} BuildLauncherProps + * @prop {URL} baseUrl + * @prop {Spec} spec + * @prop {boolean} isLaunching + * @prop {(l: boolean) => void} setIsLaunching + * @prop {PROGRESS_STATES} progressState + * @prop {(p: PROGRESS_STATES) => void} setProgressState + * + * @param {BuildLauncherProps} props + * @returns + */ +export function BuilderLauncher({ + baseUrl, + spec, + isLaunching, + setIsLaunching, + progressState, + setProgressState, +}) { + const [term, setTerm] = useState(null); + const [fitAddon, setFitAddon] = useState(null); + const [logsVisible, setLogsVisible] = useState(false); + const logBufferRef = useRef(new Array()); + useEffect(() => { + async function setup() { + if (isLaunching) { + await buildImage( + baseUrl, + spec, + term, + logBufferRef.current, + fitAddon, + setIsLaunching, + setProgressState, + setLogsVisible, + ); + } + } + setup(); + }, [isLaunching]); + return ( +
+ + +
+ ); +} diff --git a/binderhub/static/js/components/FaviconUpdater.jsx b/binderhub/static/js/components/FaviconUpdater.jsx new file mode 100644 index 000000000..5ad040a83 --- /dev/null +++ b/binderhub/static/js/components/FaviconUpdater.jsx @@ -0,0 +1,32 @@ +import ProgressIcon from "../../images/favicon/progress.ico"; +import FailIcon from "../../images/favicon/fail.ico"; +import SuccessIcon from "../../images/favicon/success.ico"; + +import { PROGRESS_STATES } from "./Progress.jsx"; + +/** + * @typedef {object} FaviconUpdaterProps + * @prop {PROGRESS_STATES} progressState + * @param {FaviconUpdaterProps} props + */ +export function FaviconUpdater({ progressState }) { + let icon; + switch (progressState) { + case PROGRESS_STATES.FAILED: { + icon = FailIcon; + break; + } + case PROGRESS_STATES.SUCCESS: { + icon = SuccessIcon; + break; + } + case PROGRESS_STATES.BUILDING: + case PROGRESS_STATES.PUSHING: + case PROGRESS_STATES.LAUNCHING: { + icon = ProgressIcon; + break; + } + } + + return ; +} diff --git a/binderhub/static/js/components/HowItWorks.jsx b/binderhub/static/js/components/HowItWorks.jsx new file mode 100644 index 000000000..e6091d758 --- /dev/null +++ b/binderhub/static/js/components/HowItWorks.jsx @@ -0,0 +1,75 @@ +export function HowItWorks() { + return ( +
+

How it works

+ +
+
+ + 1 + +
+
+

Enter your repository information

+ Provide in the above form a URL or a GitHub repository that contains + Jupyter notebooks, as well as a branch, tag, or commit hash. Launch + will build your Binder repository. If you specify a path to a notebook + file, the notebook will be opened in your browser after building. +
+
+ +
+
+ + 2 + +
+
+

We build a Docker image of your repository

+ Binder will search for a dependency file, such as requirements.txt or + environment.yml, in the repository's root directory ( + + more details on more complex dependencies in documentation + + ). The dependency files will be used to build a Docker image. If an + image has already been built for the given repository, it will not be + rebuilt. If a new commit has been made, the image will automatically + be rebuilt. +
+
+ +
+
+ + 3 + +
+
+

Interact with your notebooks in a live environment!

{" "} + JupyterHub{" "} + server will host your repository's contents. We offer you a reusable + link and badge to your live repository that you can easily share with + others. +
+
+
+ ); +} diff --git a/binderhub/static/js/components/LinkGenerator.jsx b/binderhub/static/js/components/LinkGenerator.jsx new file mode 100644 index 000000000..3f47457f4 --- /dev/null +++ b/binderhub/static/js/components/LinkGenerator.jsx @@ -0,0 +1,327 @@ +import { useEffect, useState } from "react"; +import copy from "copy-to-clipboard"; + +/** + * @typedef {object} ProviderSelectorProps + * @prop {import("../App").Provider[]} providers + * @prop {import("../App").Provider} selectedProvider + * @prop {(p: import("../App").Provider) => void} setSelectedProvider + * + * @param {ProviderSelectorProps} props + * @returns + */ +function ProviderSelector({ + providers, + selectedProvider, + setSelectedProvider, +}) { + return ( + <> +
+ + +
+ + ); +} + +function UrlSelector({ setUrlPath }) { + const KINDS = [ + { + id: "file", + displayName: "File", + placeholder: "eg. index.ipynb", + label: "File to open (in JupyterLab)", + // Using /doc/tree as that opens documents *and* notebook files + getUrlPath: (input) => `/doc/tree/${input}`, + }, + { + id: "url", + displayName: "URL", + placeholder: "eg. /rstudio", + label: "URL to open", + getUrlPath: (input) => input, + }, + ]; + + const [kind, setKind] = useState(KINDS[0]); + const [path, setPath] = useState(""); + + useEffect(() => { + if (path) { + setUrlPath(kind.getUrlPath(path)); + } else { + setUrlPath(""); + } + }, [kind, path]); + return ( + <> + +
+ setPath(e.target.value)} + /> + + +
+ + ); +} + +function makeShareableUrl(publicBaseUrl, provider, repo, ref, urlPath) { + const url = new URL(`v2/${provider.id}/${repo}/${ref}`, publicBaseUrl); + if (urlPath) { + url.searchParams.set("urlpath", urlPath); + } + return url; +} + +export function LinkGenerator({ + providers, + publicBaseUrl, + selectedProvider, + setSelectedProvider, + repo, + setRepo, + reference, + setReference, + urlPath, + setUrlPath, + isLaunching, + setIsLaunching, +}) { + const [badgeType, setBadgeType] = useState("md"); // Options are md and rst + const [badgeVisible, setBadgeVisible] = useState(false); + + let launchUrl = ""; + let badgeMarkup = ""; + + const ref = reference || selectedProvider.ref.default; + if (repo !== "" && (!selectedProvider.ref.enabled || ref !== "")) { + launchUrl = makeShareableUrl( + publicBaseUrl, + selectedProvider, + repo, + ref, + urlPath, + ).toString(); + const badgeLogoUrl = new URL("badge_logo.svg", publicBaseUrl); + if (badgeType === "md") { + badgeMarkup = `[![Binder](${badgeLogoUrl})](${launchUrl})`; + } else { + badgeMarkup = `.. image:: ${badgeLogoUrl}\n :target: ${launchUrl}`; + } + } + + return ( + <> +
+

Build and launch a repository

+
+ +
+ + { + let repo = e.target.value; + if (selectedProvider.detect && selectedProvider.detect.regex) { + // repo value *must* be detected by this regex, or it is not valid yet + const re = new RegExp(selectedProvider.detect.regex); + const results = re.exec(repo); + if ( + results !== null && + results.groups && + results.groups.repo + ) { + setRepo(results.groups.repo); + } + } else { + setRepo(e.target.value); + } + }} + /> +
+
+ +
+
+ +
+ { + setReference(e.target.value); + }} + /> +
+
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
+
+ +
+
+ Badges for your README + +
+
+
+
+ setBadgeType("md")} + > + + + setBadgeType("rst")} + > + +
+
+                {badgeMarkup ||
+                  "Fill in the fields to see a badge markup for your README."}
+              
+ +
+
+
+
+ + ); +} diff --git a/binderhub/static/loading.css b/binderhub/static/js/components/LoadingIndicator.css similarity index 64% rename from binderhub/static/loading.css rename to binderhub/static/js/components/LoadingIndicator.css index d267b113a..ea8a29120 100644 --- a/binderhub/static/loading.css +++ b/binderhub/static/js/components/LoadingIndicator.css @@ -51,21 +51,21 @@ https://ihatetomatoes.net for initial code templates.*/ } } -.error, -.error:after, -.error:before { +#loader.error, +#loader.error:after, +#loader.error:before { border-top-color: red !important; } -.error { +#loader.error { animation: spin 30s linear infinite !important; } -.error:after { +#loader.error:after { animation: spin 10s linear infinite !important; } -.error:before { +#loader.error:before { animation: spin 20s linear infinite !important; } @@ -74,53 +74,3 @@ https://ihatetomatoes.net for initial code templates.*/ .paused:before { animation-play-state: paused !important; } - -#demo-content { - padding-top: 100px; -} - -div#loader-text { - min-height: 3em; -} - -#loader-text p { - z-index: 1002; - max-width: 750px; - text-align: center; - margin: 0px auto 10px auto; -} - -#loader-text p.launching { - font-size: 2em; -} - -div#loader-links { - min-height: 6em; -} - -#loader-links p { - font-size: 1.5em; - text-align: center; - max-width: 700px; - margin: 0px auto 10px auto; -} - -div#log-container { - width: 80%; - margin: 0% 10%; -} - -.hidden { - display: none; -} - -.preview { - margin-top: 40px; - width: 70%; -} - -#nbviewer-preview > iframe { - width: 100%; - height: 80vh; - border: 1px solid #aaa; -} diff --git a/binderhub/static/js/src/loading.js b/binderhub/static/js/components/LoadingIndicator.jsx similarity index 62% rename from binderhub/static/js/src/loading.js rename to binderhub/static/js/components/LoadingIndicator.jsx index 3f0d3beaf..1886a69e4 100644 --- a/binderhub/static/js/src/loading.js +++ b/binderhub/static/js/components/LoadingIndicator.jsx @@ -1,7 +1,10 @@ +import { useEffect, useState } from "react"; +import "./LoadingIndicator.css"; +import { PROGRESS_STATES } from "./Progress.jsx"; /** * List of help messages we will cycle through randomly in the loading page */ -const helpMessages = [ +const HELP_MESSAGES = [ 'New to Binder? Check out the Binder Documentation for more information.', 'You can learn more about building your own Binder repositories in the Binder community documentation.', 'We use the repo2docker tool to automatically build the environment in which to run your code.', @@ -18,19 +21,42 @@ const helpMessages = [ ]; /** - * Display a randomly picked help message in the loading page + * @typedef {object} LoadingIndicatorProps + * @prop {PROGRESS_STATES} progressState + * @param {LoadingIndicatorProps} props */ -export function nextHelpText() { - const text = $("div#loader-links p.text-center"); - let msg; - if (text !== null) { - if (!text.hasClass("longLaunch")) { - // Pick a random help message and update - msg = helpMessages[Math.floor(Math.random() * helpMessages.length)]; - } else { - msg = - "Your session is taking longer than usual to start!
Check the log messages below to see what is happening."; - } - text.html(msg); - } +export function LoadingIndicator({ progressState }) { + const [currentMessage, setCurrentMessage] = useState(HELP_MESSAGES[0]); + + useEffect(() => { + const intervalId = setInterval(() => { + const newMessage = + HELP_MESSAGES[parseInt(Math.random() * (HELP_MESSAGES.length - 1))]; + console.log(newMessage); + setCurrentMessage(newMessage); + }, 6 * 1000); + + return () => clearInterval(intervalId); + }, []); + + return ( +
+
+ {progressState === PROGRESS_STATES.FAILED ? ( +

+ Launching your Binder failed! See the logs below for more information. +

+ ) : ( + <> +

Launching your Binder...

+
+

+
+ + )} +
+ ); } diff --git a/binderhub/static/js/components/NBViewerIFrame.jsx b/binderhub/static/js/components/NBViewerIFrame.jsx new file mode 100644 index 000000000..b4f073711 --- /dev/null +++ b/binderhub/static/js/components/NBViewerIFrame.jsx @@ -0,0 +1,47 @@ +import { Spec } from "../spec"; + +/** + * @typedef {object} NBViewerIFrameProps + * @prop {Spec} spec + * @param {NBViewerIFrameProps} props + * @returns + */ +export function NBViewerIFrame({ spec }) { + // We only support GitHub links as preview right now + if (!spec.buildSpec.startsWith("gh/")) { + return; + } + + const [_, org, repo, ref] = spec.buildSpec.split("/"); + + let urlPath = decodeURI(spec.urlPath); + // Handle cases where urlPath starts with a `/` + urlPath = urlPath.replace(/^\//, ""); + let filePath = ""; + if (urlPath.startsWith("doc/tree/")) { + filePath = urlPath.replace(/^doc\/tree\//, ""); + } else if (urlPath.startsWith("tree/")) { + filePath = urlPath.replace(/^tree\//, ""); + } + + let url; + if (filePath) { + url = `https://nbviewer.jupyter.org/github/${org}/${repo}/blob/${ref}/${filePath}`; + } else { + url = `https://nbviewer.jupyter.org/github/${org}/${repo}/tree/${ref}`; + } + + return ( +
+

+ Here is a non-interactive preview on{" "} + + nbviewer + {" "} + while we start a server for you.
+ Your binder will open automatically when it is ready. +

+ +
+ ); +} diff --git a/binderhub/static/js/components/Progress.jsx b/binderhub/static/js/components/Progress.jsx new file mode 100644 index 000000000..d5896f79f --- /dev/null +++ b/binderhub/static/js/components/Progress.jsx @@ -0,0 +1,82 @@ +/** + * @enum {string} + */ +export const PROGRESS_STATES = { + WAITING: "Waiting", + BUILDING: "Building", + PUSHING: "Pushing", + LAUNCHING: "Launching", + SUCCESS: "Success", + FAILED: "Failed", +}; + +const progressDisplay = {}; +(progressDisplay[PROGRESS_STATES.WAITING] = { + precursors: [], + widthPercent: "10", + label: "Waiting", + className: "text-bg-danger", +}), + (progressDisplay[PROGRESS_STATES.BUILDING] = { + precursors: [PROGRESS_STATES.WAITING], + widthPercent: "50", + label: "Building", + className: "text-bg-warning", + }); + +progressDisplay[PROGRESS_STATES.PUSHING] = { + precursors: [PROGRESS_STATES.WAITING, PROGRESS_STATES.BUILDING], + widthPercent: "30", + label: "Pushing", + className: "text-bg-info", +}; + +progressDisplay[PROGRESS_STATES.LAUNCHING] = { + precursors: [ + PROGRESS_STATES.WAITING, + PROGRESS_STATES.BUILDING, + PROGRESS_STATES.PUSHING, + ], + widthPercent: "10", + label: "Launching", + className: "text-bg-success", +}; + +progressDisplay[PROGRESS_STATES.SUCCESS] = + progressDisplay[PROGRESS_STATES.LAUNCHING]; + +progressDisplay[PROGRESS_STATES.FAILED] = { + precursors: [], + widthPercent: "100", + label: "Failed", + className: "text-bg-danger", +}; + +/** + * @typedef {object} ProgressProps + * @prop {PROGRESS_STATES} progressState + * @param {ProgressProps} props + */ +export function Progress({ progressState }) { + return ( +
+ {progressState === null + ? "" + : progressDisplay[progressState].precursors + .concat([progressState]) + .map((s) => ( +
+ {progressDisplay[s].label} +
+ ))} +
+ ); +} diff --git a/binderhub/static/js/index.d.ts b/binderhub/static/js/index.d.ts new file mode 100644 index 000000000..427887634 --- /dev/null +++ b/binderhub/static/js/index.d.ts @@ -0,0 +1,2 @@ +// Tell typescript to be quiet about .ico files we use for favicons +declare module "*.ico"; diff --git a/binderhub/static/js/index.js b/binderhub/static/js/index.js deleted file mode 100644 index dbd9639ab..000000000 --- a/binderhub/static/js/index.js +++ /dev/null @@ -1,253 +0,0 @@ -/* If this file gets over 200 lines of code long (not counting docs / comments), start using a framework - */ -import ClipboardJS from "clipboard"; - -import { BinderRepository } from "@jupyterhub/binderhub-client"; -import { updatePathText } from "./src/path"; -import { nextHelpText } from "./src/loading"; -import { updateFavicon } from "./src/favicon"; - -import "xterm/css/xterm.css"; - -// Include just the bootstrap components we use -import "bootstrap/js/dropdown"; -import "bootstrap/dist/css/bootstrap.min.css"; -import "bootstrap/dist/css/bootstrap-theme.min.css"; - -import "../index.css"; -import { setUpLog } from "./src/log"; -import { updateUrls } from "./src/urls"; -import { getBuildFormValues } from "./src/form"; -import { updateRepoText } from "./src/repo"; - -/** - * @type {URL} - * Base URL of this binderhub installation. - * - * Guaranteed to have a leading & trailing slash by the binderhub python configuration. - */ -const BASE_URL = new URL( - document.getElementById("base-url").dataset.url, - document.location.origin, -); - -const badge_base_url = document.getElementById("badge-base-url").dataset.url; -/** - * @type {URL} - * Base URL to use for both badge images as well as launch links. - * - * If not explicitly set, will default to BASE_URL. Primarily set up different than BASE_URL - * when used as part of a federation - */ -const BADGE_BASE_URL = badge_base_url - ? new URL(badge_base_url, document.location.origin) - : BASE_URL; - -async function build(providerSpec, log, fitAddon, path, pathType) { - updateFavicon(new URL("favicon_building.ico", BASE_URL)); - // split provider prefix off of providerSpec - const spec = providerSpec.slice(providerSpec.indexOf("/") + 1); - // Update the text of the loading page if it exists - if ($("div#loader-text").length > 0) { - $("div#loader-text p.launching").text( - "Starting repository: " + decodeURIComponent(spec), - ); - } - - $("#build-progress .progress-bar").addClass("hidden"); - log.clear(); - - $(".on-build").removeClass("hidden"); - - const buildToken = $("#build-token").data("token"); - const apiToken = $("#api-token").data("token"); - const buildEndpointUrl = new URL("build", BASE_URL); - const image = new BinderRepository(providerSpec, buildEndpointUrl, { - apiToken, - buildToken, - }); - - for await (const data of image.fetch()) { - // Write message to the log terminal if there is a message - if (data.message !== undefined) { - log.writeAndStore(data.message); - fitAddon.fit(); - } else { - console.log(data); - } - - switch (data.phase) { - case "waiting": { - $("#phase-waiting").removeClass("hidden"); - break; - } - case "building": { - $("#phase-building").removeClass("hidden"); - log.show(); - break; - } - case "pushing": { - $("#phase-pushing").removeClass("hidden"); - break; - } - case "failed": { - $("#build-progress .progress-bar").addClass("hidden"); - $("#phase-failed").removeClass("hidden"); - - $("#loader").addClass("paused"); - - // If we fail for any reason, show an error message and logs - updateFavicon(new URL("favicon_fail.ico", BASE_URL)); - log.show(); - if ($("div#loader-text").length > 0) { - $("#loader").addClass("error"); - $("div#loader-text p.launching").html( - "Error loading " + spec + "!
See logs below for details.", - ); - } - image.close(); - break; - } - case "built": { - $("#phase-already-built").removeClass("hidden"); - $("#phase-launching").removeClass("hidden"); - updateFavicon(new URL("favicon_success.ico", BASE_URL)); - break; - } - case "ready": { - image.close(); - // If data.url is an absolute URL, it'll be used. Else, it'll be interpreted - // relative to current page's URL. - const serverUrl = new URL(data.url, window.location.href); - // user server is ready, redirect to there - window.location.href = image.getFullRedirectURL( - serverUrl, - data.token, - path, - pathType, - ); - break; - } - default: { - console.log("Unknown phase in response from server"); - console.log(data); - break; - } - } - } - return image; -} - -function indexMain() { - const [log, fitAddon] = setUpLog(); - - // setup badge dropdown and default values. - updateUrls(BADGE_BASE_URL); - - $("#provider_prefix_sel li").click(function (event) { - event.preventDefault(); - - $("#provider_prefix-selected").text($(this).text()); - $("#provider_prefix").val($(this).attr("value")); - updateRepoText(BASE_URL); - updateUrls(BADGE_BASE_URL); - }); - - $("#url-or-file-btn") - .find("a") - .click(function (evt) { - evt.preventDefault(); - - $("#url-or-file-selected").text($(this).text()); - updatePathText(); - updateUrls(BADGE_BASE_URL); - }); - updatePathText(); - updateRepoText(BASE_URL); - - $("#repository").on("keyup paste change", function () { - updateUrls(BADGE_BASE_URL); - }); - - $("#ref").on("keyup paste change", function () { - updateUrls(BADGE_BASE_URL); - }); - - $("#filepath").on("keyup paste change", function () { - updateUrls(BADGE_BASE_URL); - }); - - $("#toggle-badge-snippet").on("click", function () { - const badgeSnippets = $("#badge-snippets"); - if (badgeSnippets.hasClass("hidden")) { - badgeSnippets.removeClass("hidden"); - $("#badge-snippet-caret").removeClass("glyphicon-triangle-right"); - $("#badge-snippet-caret").addClass("glyphicon-triangle-bottom"); - } else { - badgeSnippets.addClass("hidden"); - $("#badge-snippet-caret").removeClass("glyphicon-triangle-bottom"); - $("#badge-snippet-caret").addClass("glyphicon-triangle-right"); - } - - return false; - }); - - $("#build-form").submit(async function (e) { - e.preventDefault(); - const formValues = getBuildFormValues(); - updateUrls(BADGE_BASE_URL, formValues); - await build( - formValues.providerPrefix + "/" + formValues.repo + "/" + formValues.ref, - log, - fitAddon, - formValues.path, - formValues.pathType, - ); - }); -} - -async function loadingMain(providerSpec) { - const [log, fitAddon] = setUpLog(); - // retrieve (encoded) filepath/urlpath from URL - // URLSearchParams.get returns the decoded value, - // that is good because it is the real value and '/'s will be trimmed in `launch` - const params = new URL(location.href).searchParams; - let pathType, path; - path = params.get("urlpath"); - if (path) { - pathType = "url"; - } else { - path = params.get("labpath"); - if (path) { - pathType = "lab"; - } else { - path = params.get("filepath"); - if (path) { - pathType = "file"; - } - } - } - await build(providerSpec, log, fitAddon, path, pathType); - - // Looping through help text every few seconds - const launchMessageInterval = 6 * 1000; - setInterval(nextHelpText, launchMessageInterval); - - // If we have a long launch, add a class so we display a long launch msg - const launchTimeout = 120 * 1000; - setTimeout(() => { - $("div#loader-links p.text-center").addClass("longLaunch"); - nextHelpText(); - }, launchTimeout); - - return false; -} - -// export entrypoints -window.loadingMain = loadingMain; -window.indexMain = indexMain; - -// Load the clipboard after the page loads so it can find the buttons it needs -window.onload = function () { - new ClipboardJS(".clipboard"); -}; diff --git a/binderhub/static/js/index.scss b/binderhub/static/js/index.scss new file mode 100644 index 000000000..f02696195 --- /dev/null +++ b/binderhub/static/js/index.scss @@ -0,0 +1,51 @@ +@import "bootstrap/scss/functions"; + +// Theming overrides +$primary: rgb(223, 132, 41); +$custom-colors: ( + "primary": $primary, +); + +// Import these after theming overrides so they pick up these variables +@import "bootstrap/scss/variables"; +@import "bootstrap/scss/variables-dark"; +@import "bootstrap/scss/maps"; +@import "bootstrap/scss/mixins"; +@import "bootstrap/scss/utilities"; +@import "bootstrap/scss/root"; +@import "bootstrap/scss/reboot"; + +// Merge the maps +$theme-colors: map-merge($theme-colors, $custom-colors); + +@import "bootstrap/scss/bootstrap"; + +// Font choices + +body { + font-family: "Clear Sans"; + font-weight: 300; +} + +form { + font-weight: 400; +} + +.btn-primary, +.btn-primary:hover { + color: $white; +} + +a { + text-decoration: none; +} + +// Could not replicate this style with just utility classes unfortunately +.circle-point { + border: 5px solid; + padding: 2px 9px; + border-radius: 50%; + font-weight: bold; +} + +@import "bootstrap-icons/font/bootstrap-icons.css"; diff --git a/binderhub/static/js/pages/AboutPage.jsx b/binderhub/static/js/pages/AboutPage.jsx new file mode 100644 index 000000000..9f1f37a65 --- /dev/null +++ b/binderhub/static/js/pages/AboutPage.jsx @@ -0,0 +1,17 @@ +export function AboutPage({ aboutMessage, binderVersion }) { + return ( +
+

BinderHub

+
+

+ This website is powered by{" "} + BinderHub v + {binderVersion} +

+ {aboutMessage && ( +

+ )} +
+
+ ); +} diff --git a/binderhub/static/js/pages/HomePage.jsx b/binderhub/static/js/pages/HomePage.jsx new file mode 100644 index 000000000..7a3dfe34e --- /dev/null +++ b/binderhub/static/js/pages/HomePage.jsx @@ -0,0 +1,84 @@ +import { LinkGenerator } from "../components/LinkGenerator.jsx"; +import { BuilderLauncher } from "../components/BuilderLauncher.jsx"; +import { HowItWorks } from "../components/HowItWorks.jsx"; +import { useEffect, useState } from "react"; +import { FaviconUpdater } from "../components/FaviconUpdater.jsx"; +import { Spec, LaunchSpec } from "../spec.js"; + +/** + * @typedef {object} HomePageProps + * @prop {import("../App.jsx").Provider[]} providers + * @prop {URL} publicBaseUrl + * @prop {URL} baseUrl + * @param {HomePageProps} props + */ +export function HomePage({ providers, publicBaseUrl, baseUrl }) { + const defaultProvider = providers[0]; + const [selectedProvider, setSelectedProvider] = useState(defaultProvider); + const [repo, setRepo] = useState(""); + const [ref, setRef] = useState(""); + const [urlPath, setUrlPath] = useState(""); + const [isLaunching, setIsLaunching] = useState(false); + const [spec, setSpec] = useState(""); + const [progressState, setProgressState] = useState(null); + + useEffect(() => { + let actualRef = ""; + if (selectedProvider.ref.enabled) { + actualRef = ref !== "" ? ref : selectedProvider.ref.default; + } + setSpec( + new Spec( + `${selectedProvider.id}/${repo}/${actualRef}`, + new LaunchSpec(urlPath), + ), + ); + }, [selectedProvider, repo, ref, urlPath]); + + return ( + <> +
+
Turn a Git repo into a collection of interactive notebooks
+

+ Have a repository full of Jupyter notebooks? With Binder, open those + notebooks in an executable environment, making your code immediately + reproducible by anyone, anywhere. +

+

+ New to Binder? Get started with a{" "} + + Zero-to-Binder tutorial + {" "} + in Julia, Python, or R. +

+
+ + + + + + ); +} diff --git a/binderhub/static/js/pages/LoadingPage.jsx b/binderhub/static/js/pages/LoadingPage.jsx new file mode 100644 index 000000000..99468cdd4 --- /dev/null +++ b/binderhub/static/js/pages/LoadingPage.jsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react"; +import { BuilderLauncher } from "../components/BuilderLauncher.jsx"; +import { useParams } from "react-router"; +import { useSearchParams } from "react-router-dom"; +import { NBViewerIFrame } from "../components/NBViewerIFrame.jsx"; +import { LoadingIndicator } from "../components/LoadingIndicator.jsx"; +import { FaviconUpdater } from "../components/FaviconUpdater.jsx"; +import { LaunchSpec, Spec } from "../spec.js"; + +/** + * @typedef {object} LoadingPageProps + * @prop {URL} baseUrl + * @param {LoadingPageProps} props + * @returns + */ +export function LoadingPage({ baseUrl }) { + const [progressState, setProgressState] = useState(null); + + const params = useParams(); + const buildSpec = params["*"]; + + const [searchParams, _] = useSearchParams(); + + const [isLaunching, setIsLaunching] = useState(false); + + const spec = new Spec(buildSpec, LaunchSpec.fromSearchParams(searchParams)); + + useEffect(() => { + // Start launching after the DOM has fully loaded + setTimeout(() => setIsLaunching(true), 1); + }, []); + + return ( + <> + + + + + + + ); +} diff --git a/binderhub/static/js/spec.js b/binderhub/static/js/spec.js new file mode 100644 index 000000000..28520838e --- /dev/null +++ b/binderhub/static/js/spec.js @@ -0,0 +1,72 @@ +export class LaunchSpec { + /** + * + * @param {string} urlPath Path inside the Jupyter server to redirect the user to after launching + */ + constructor(urlPath) { + this.urlPath = urlPath; + // Ensure no leading / here + this.urlPath = this.urlPath.replace(/^\/*/, ""); + } + + /** + * Return a URL to redirect user to for use with this launch specification + * + * @param {URL} serverUrl Fully qualified URL to a running Jupyter Server + * @param {string} token Authentication token to pass to the Jupyter Server + * + * @returns {URL} + */ + getJupyterServerRedirectUrl(serverUrl, token) { + const redirectUrl = new URL(this.urlPath, serverUrl); + redirectUrl.searchParams.append("token", token); + return redirectUrl; + } + + /** + * Create a LaunchSpec from given query parameters in the URL + * + * Handles backwards compatible parameters as needed. + * + * @param {URLSearchParams} searchParams + * + * @returns {LaunchSpec} + */ + static fromSearchParams(searchParams) { + let urlPath = searchParams.get("urlpath"); + if (urlPath === null) { + urlPath = ""; + } + + // Handle legacy parameters for opening URLs after launching + // labpath and filepath + if (searchParams.has("labpath")) { + // Trim trailing / on file paths + const filePath = searchParams.get("labpath").replace(/(\/$)/g, ""); + urlPath = `doc/tree/${encodeURI(filePath)}`; + } else if (searchParams.has("filepath")) { + // Trim trailing / on file paths + const filePath = searchParams.get("filepath").replace(/(\/$)/g, ""); + urlPath = `tree/${encodeURI(filePath)}`; + } + + return new LaunchSpec(urlPath); + } +} + +/** + * A full binder specification + * + * Includes a *build* specification (determining what is built), and a + * *launch* specification (determining what is launched). + */ +export class Spec { + /** + * @param {string} buildSpec Build specification, passed directly to binderhub API + * @param {LaunchSpec} launchSpec Launch specification, determining what is launched + */ + constructor(buildSpec, launchSpec) { + this.buildSpec = buildSpec; + this.launchSpec = launchSpec; + } +} diff --git a/binderhub/static/js/src/favicon.js b/binderhub/static/js/src/favicon.js deleted file mode 100644 index ae6592cfa..000000000 --- a/binderhub/static/js/src/favicon.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Dynamically set current page's favicon. - * - * @param {URL} href Path to Favicon to use - */ -function updateFavicon(href) { - let link = document.querySelector("link[rel*='icon']"); - if (!link) { - link = document.createElement("link"); - document.getElementsByTagName("head")[0].appendChild(link); - } - link.type = "image/x-icon"; - link.rel = "shortcut icon"; - link.href = href; -} - -export { updateFavicon }; diff --git a/binderhub/static/js/src/favicon.test.js b/binderhub/static/js/src/favicon.test.js deleted file mode 100644 index c73cab495..000000000 --- a/binderhub/static/js/src/favicon.test.js +++ /dev/null @@ -1,35 +0,0 @@ -import { updateFavicon } from "./favicon"; - -afterEach(() => { - // Clear out HEAD after each test run, so our DOM is clean. - // Jest does *not* clear out the DOM between test runs on the same file! - document.querySelector("head").innerHTML = ""; -}); - -test("Setting favicon when there is none works", () => { - expect(document.querySelector("link[rel*='icon']")).toBeNull(); - - updateFavicon("https://example.com/somefile.png"); - - expect(document.querySelector("link[rel*='icon']").href).toBe( - "https://example.com/somefile.png", - ); -}); - -test("Setting favicon multiple times works without leaking link tags", () => { - expect(document.querySelector("link[rel*='icon']")).toBeNull(); - - updateFavicon("https://example.com/somefile.png"); - - expect(document.querySelector("link[rel*='icon']").href).toBe( - "https://example.com/somefile.png", - ); - expect(document.querySelectorAll("link[rel*='icon']").length).toBe(1); - - updateFavicon("https://example.com/some-other-file.png"); - - expect(document.querySelector("link[rel*='icon']").href).toBe( - "https://example.com/some-other-file.png", - ); - expect(document.querySelectorAll("link[rel*='icon']").length).toBe(1); -}); diff --git a/binderhub/static/js/src/form.js b/binderhub/static/js/src/form.js deleted file mode 100644 index 1bf70e6f1..000000000 --- a/binderhub/static/js/src/form.js +++ /dev/null @@ -1,47 +0,0 @@ -import { getPathType } from "./path"; - -/** - * Parse current values in form and return them with appropriate URL encoding - * @typedef FormValues - * @prop {string} providerPrefix prefix denoting what provider was selected - * @prop {string} repo repo to build - * @prop {[string]} ref optional ref in this repo to build - * @prop {string} path Path to launch after this repo has been built - * @prop {string} pathType Type of thing to open path with (raw url, notebook file, lab, etc) - * @returns {} - */ -export function getBuildFormValues() { - const providerPrefix = $("#provider_prefix").val().trim(); - let repo = $("#repository").val().trim(); - if (providerPrefix !== "git") { - repo = repo.replace(/^(https?:\/\/)?gist.github.com\//, ""); - repo = repo.replace(/^(https?:\/\/)?github.com\//, ""); - repo = repo.replace(/^(https?:\/\/)?gitlab.com\//, ""); - } - // trim trailing or leading '/' on repo - repo = repo.replace(/(^\/)|(\/?$)/g, ""); - // git providers encode the URL of the git repository as the repo - // argument. - if (repo.includes("://") || providerPrefix === "gl") { - repo = encodeURIComponent(repo); - } - - let ref = $("#ref").val().trim() || $("#ref").attr("placeholder"); - if ( - providerPrefix === "zenodo" || - providerPrefix === "figshare" || - providerPrefix === "dataverse" || - providerPrefix === "hydroshare" || - providerPrefix === "ckan" - ) { - ref = ""; - } - const path = $("#filepath").val().trim(); - return { - providerPrefix: providerPrefix, - repo: repo, - ref: ref, - path: path, - pathType: getPathType(), - }; -} diff --git a/binderhub/static/js/src/log.js b/binderhub/static/js/src/log.js deleted file mode 100644 index f37ed5078..000000000 --- a/binderhub/static/js/src/log.js +++ /dev/null @@ -1,75 +0,0 @@ -import { Terminal } from "xterm"; -import { FitAddon } from "xterm-addon-fit"; - -/** - * Set up a read only xterm.js based terminal, augmented with some additional methods, to display log lines - * - * @returns Array of the xterm.js instance to write to, and a FitAddon instance to use for resizing the xterm appropriately - */ -export function setUpLog() { - const log = new Terminal({ - convertEol: true, - disableStdin: true, - }); - - const fitAddon = new FitAddon(); - log.loadAddon(fitAddon); - const logMessages = []; - - log.open(document.getElementById("log"), false); - fitAddon.fit(); - - $(window).resize(function () { - fitAddon.fit(); - }); - - const $panelBody = $("div.panel-body"); - - /** - * Show the log terminal - */ - log.show = function () { - $("#toggle-logs button.toggle").text("hide"); - $panelBody.removeClass("hidden"); - }; - - /** - * Hide the log terminal - */ - log.hide = function () { - $("#toggle-logs button.toggle").text("show"); - $panelBody.addClass("hidden"); - }; - - /** - * Toggle visibility of the log terminal - */ - log.toggle = function () { - if ($panelBody.hasClass("hidden")) { - log.show(); - } else { - log.hide(); - } - }; - - $("#view-raw-logs").on("click", function (ev) { - const blob = new Blob([logMessages.join("")], { type: "text/plain" }); - this.href = window.URL.createObjectURL(blob); - // Prevent the toggle action from firing - ev.stopPropagation(); - }); - - $("#toggle-logs").click(log.toggle); - - /** - * Write message to xterm and store it in the download buffer - * - * @param {string} msg Message to write to the terminal & add to message buffer - */ - log.writeAndStore = function (msg) { - logMessages.push(msg); - log.write(msg); - }; - - return [log, fitAddon]; -} diff --git a/binderhub/static/js/src/path.js b/binderhub/static/js/src/path.js deleted file mode 100644 index 618ed9561..000000000 --- a/binderhub/static/js/src/path.js +++ /dev/null @@ -1,27 +0,0 @@ -export function getPathType() { - // return path type. 'file' or 'url' - const element = document.getElementById("url-or-file-selected"); - let pathType = element.innerText.trim().toLowerCase(); - if (pathType === "file") { - // selecting a 'file' in the form opens with jupyterlab - // avoids backward-incompatibility with old `filepath` urls, - // which still open old UI - pathType = "lab"; - } - return pathType; -} - -export function updatePathText() { - const pathType = getPathType(); - let text; - if (pathType === "file" || pathType === "lab") { - text = "Path to a notebook file (optional)"; - } else { - text = "URL to open (optional)"; - } - const filePathElement = document.getElementById("filepath"); - filePathElement.setAttribute("placeholder", text); - - const filePathElementLabel = document.querySelector("label[for=filepath]"); - filePathElementLabel.innerText = text; -} diff --git a/binderhub/static/js/src/repo.js b/binderhub/static/js/src/repo.js deleted file mode 100644 index 17a3524b2..000000000 --- a/binderhub/static/js/src/repo.js +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Dict holding cached values of API request to _config endpoint - */ -let configDict = {}; - -function setLabels() { - const provider = $("#provider_prefix").val(); - const text = configDict[provider]["text"]; - const tagText = configDict[provider]["tag_text"]; - const refPropDisabled = configDict[provider]["ref_prop_disabled"]; - const labelPropDisabled = configDict[provider]["label_prop_disabled"]; - const placeholder = "HEAD"; - - $("#ref").attr("placeholder", placeholder).prop("disabled", refPropDisabled); - $("label[for=ref]").text(tagText).prop("disabled", labelPropDisabled); - $("#repository").attr("placeholder", text); - $("label[for=repository]").text(text); -} - -/** - * Update labels for various inputboxes based on user selection of repo provider - * - * @param {URL} baseUrl Base URL to use for constructing path to _config endpoint - */ -export function updateRepoText(baseUrl) { - if (Object.keys(configDict).length === 0) { - const xsrf = $("#xsrf-token").data("token"); - const apiToken = $("#api-token").data("token"); - const configUrl = new URL("_config", baseUrl); - const headers = {}; - if (apiToken && apiToken.length > 0) { - headers["Authorization"] = `Bearer ${apiToken}`; - } else if (xsrf && xsrf.length > 0) { - headers["X-Xsrftoken"] = xsrf; - } - fetch(configUrl, { headers }).then((resp) => { - resp.json().then((data) => { - configDict = data; - setLabels(); - }); - }); - } else { - setLabels(); - } -} diff --git a/binderhub/static/js/src/urls.js b/binderhub/static/js/src/urls.js deleted file mode 100644 index 698ccfdb6..000000000 --- a/binderhub/static/js/src/urls.js +++ /dev/null @@ -1,39 +0,0 @@ -import { getBuildFormValues } from "./form"; -import { - makeShareableBinderURL, - makeBadgeMarkup, -} from "@jupyterhub/binderhub-client"; - -/** - * Update the shareable URL and badge snippets in the UI based on values user has entered in the form - */ -export function updateUrls(publicBaseUrl, formValues) { - if (typeof formValues === "undefined") { - formValues = getBuildFormValues(); - } - if (formValues.repo) { - const url = makeShareableBinderURL( - publicBaseUrl, - formValues.providerPrefix, - formValues.repo, - formValues.ref, - formValues.path, - formValues.pathType, - ); - - // update URLs and links (badges, etc.) - $("#badge-link").attr("href", url); - $("#basic-url-snippet").text(url); - $("#markdown-badge-snippet").text( - makeBadgeMarkup(publicBaseUrl, url, "markdown"), - ); - $("#rst-badge-snippet").text(makeBadgeMarkup(publicBaseUrl, url, "rst")); - } else { - ["#basic-url-snippet", "#markdown-badge-snippet", "#rst-badge-snippet"].map( - function (item) { - const el = $(item); - el.text(el.attr("data-default")); - }, - ); - } -} diff --git a/binderhub/templates/about.html b/binderhub/templates/about.html deleted file mode 100644 index b18f14b70..000000000 --- a/binderhub/templates/about.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "page.html" %} - -{% block main %} -
-
-
- {% block header %} - - {% endblock header %} -
-
-
-{% endblock main %} - -{% block footer %} -{% endblock footer %} diff --git a/binderhub/templates/index.html b/binderhub/templates/index.html deleted file mode 100644 index cc55b89a7..000000000 --- a/binderhub/templates/index.html +++ /dev/null @@ -1,206 +0,0 @@ -{% extends "page.html" %} - -{% block head %} - - - - - -{{ super() }} -{% endblock head %} - -{% block main %} -
-
-
- {% block header %} - -
-

New to Binder? Get started with a Zero-to-Binder tutorial in Julia, Python, or R.

-
- {% endblock header %} - - {% block form %} -
-

Build and launch a repository

- -
- -
-
- - -
- -
-
-
-
- - -
-
- -
- -
- - -
-
-
- -
-
- -
-
-
- - -
- -
-
Fill in the fields to see a URL for sharing your Binder.
- Copy to clipboard -
-
- -
- - -
- - - - -
- {% endblock form %} -
-
- {% block how_it_works %} -
-

How it works

- -
-
- 1 -
-
- Enter your repository information
Provide in the above form a URL or a GitHub repository that contains Jupyter notebooks, as well as a branch, tag, or commit hash. Launch will build your Binder repository. If you specify a path to a notebook file, the notebook will be opened in your browser after building. -
-
- -
-
- 2 -
-
- We build a Docker image of your repository
Binder will search for a dependency file, such as requirements.txt or environment.yml, in the repository's root directory (more details on more complex dependencies in documentation). The dependency files will be used to build a Docker image. If an image has already been built for the given repository, it will not be rebuilt. If a new commit has been made, the image will automatically be rebuilt. -
-
- -
-
- 3 -
-
- Interact with your notebooks in a live environment!
A JupyterHub server will host your repository's contents. We offer you a reusable link and badge to your live repository that you can easily share with others. -
-
-
- {% endblock how_it_works %} -
-{% endblock main %} - -{% block footer %} -{{ super () }} - -{% endblock footer %} diff --git a/binderhub/templates/loading.html b/binderhub/templates/loading.html deleted file mode 100644 index 4fe8af1b0..000000000 --- a/binderhub/templates/loading.html +++ /dev/null @@ -1,68 +0,0 @@ -{% extends "page.html" %} - -{% block meta_social %} - - - - - - - -{% endblock meta_social %} - -{% block head %} - - - - - -{{ super() }} - - -{% endblock head %} - -{% block main %} -
-
-

Launching your Binder...

-
- - - - -{% block preview %} -{% if nbviewer_url %} -
-

-Here's a non-interactive preview on -nbviewer -while we start a server for you. -Your binder will open automatically when it is ready. -

-
- -
-
-{% endif %} -{% endblock preview %} - -{% endblock main %} - -{% block footer %} - -{% endblock footer %} diff --git a/binderhub/templates/page.html b/binderhub/templates/page.html index 5b62e83f9..6b6780d27 100644 --- a/binderhub/templates/page.html +++ b/binderhub/templates/page.html @@ -5,76 +5,33 @@ {% block title %}Binder{% endblock %} {% block meta_social %} {# Social media previews #} - - + + {% endblock meta_social %} - {% block head %} - {% endblock head %} + + - {% block body %} - - {% if banner %} - - {% endif %} - - {% block logo %} -
-
-
- -
-
-
- {% endblock logo %} - - {% block main %} - {% endblock main %} +
+ - {% block footer %} -
-
-

questions?
join the discussion, read the docs, see the code

-
-
- {% endblock footer %} + - {% if google_analytics_code %} - +{% endfor %} +{% endif %} - ga('create', '{{ google_analytics_code }}', '{{ google_analytics_domain }}', - {'storage': 'none'}); - ga('set', 'anonymizeIp', true); - ga('send', 'pageview'); - } - - {% endif %} - {% if extra_footer_scripts %} - {% for script in extra_footer_scripts|dictsort %} - - {% endfor %} - {% endif %} - {% endblock body %} - diff --git a/js/packages/binderhub-client/lib/index.js b/js/packages/binderhub-client/lib/index.js index f33f35600..ee474d895 100644 --- a/js/packages/binderhub-client/lib/index.js +++ b/js/packages/binderhub-client/lib/index.js @@ -208,111 +208,4 @@ export class BinderRepository { this.abortController = null; } } - - /** - * Get URL to redirect user to on a Jupyter Server to display a given path - - * @param {URL} serverUrl URL to the running jupyter server - * @param {string} token Secret token used to authenticate to the jupyter server - * @param {string} [path] The path of the file or url suffix to launch the user into - * @param {string} [pathType] One of "lab", "file" or "url", denoting what kinda path we are launching the user into - * - * @returns {URL} A URL to redirect the user to - */ - getFullRedirectURL(serverUrl, token, path, pathType) { - // Make a copy of the URL so we don't mangle the original - let url = new URL(serverUrl); - if (path) { - // Ensure there is a trailing / in serverUrl - if (!url.pathname.endsWith("/")) { - url.pathname += "/"; - } - // trim leading '/' from path to launch users into - path = path.replace(/(^\/)/g, ""); - - if (pathType === "lab") { - // The path is a specific *file* we should open with JupyterLab - // trim trailing / on file paths - path = path.replace(/(\/$)/g, ""); - - // /doc/tree is safe because it allows redirect to files - url = new URL("doc/tree/" + encodeURI(path), url); - } else if (pathType === "file") { - // The path is a specific file we should open with *classic notebook* - - // trim trailing / on file paths - path = path.replace(/(\/$)/g, ""); - - url = new URL("tree/" + encodeURI(path), url); - } else { - // pathType is 'url' and we should just pass it on - url = new URL(path, url); - } - } - - url.searchParams.append("token", token); - return url; - } -} - -/** - * Generate a shareable binder URL for given repository - * - * @param {URL} publicBaseUrl Base URL to use for making public URLs. Must end with a trailing slash. - * @param {string} providerPrefix prefix denoting what provider was selected - * @param {string} repository repo to build - * @param {string} ref optional ref in this repo to build - * @param {string} [path] Path to launch after this repo has been built - * @param {string} [pathType] Type of thing to open path with (raw url, notebook file, lab, etc) - * - * @returns {URL} A URL that can be shared with others, and clicking which will launch the repo - */ -export function makeShareableBinderURL( - publicBaseUrl, - providerPrefix, - repository, - ref, - path, - pathType, -) { - if (!publicBaseUrl.pathname.endsWith("/")) { - throw new Error( - `publicBaseUrl must end with a trailing slash, got ${publicBaseUrl}`, - ); - } - const url = new URL( - `v2/${providerPrefix}/${repository}/${ref}`, - publicBaseUrl, - ); - if (path && path.length > 0) { - url.searchParams.append(`${pathType}path`, path); - } - return url; -} - -/** - * Generate markup that people can put on their README or documentation to link to a specific binder - * - * @param {URL} publicBaseUrl Base URL to use for making public URLs - * @param {URL} url Link target URL that represents this binder installation - * @param {string} syntax Kind of markup to generate. Supports 'markdown' and 'rst' - * @returns {string} - */ -export function makeBadgeMarkup(publicBaseUrl, url, syntax) { - if (!publicBaseUrl.pathname.endsWith("/")) { - throw new Error( - `publicBaseUrl must end with a trailing slash, got ${publicBaseUrl}`, - ); - } - const badgeImageUrl = new URL("badge_logo.svg", publicBaseUrl); - - if (syntax === "markdown") { - return `[![Binder](${badgeImageUrl})](${url})`; - } else if (syntax === "rst") { - return `.. image:: ${badgeImageUrl}\n :target: ${url}`; - } else { - throw new Error( - `Only markdown or rst badges are supported, got ${syntax} instead`, - ); - } } diff --git a/js/packages/binderhub-client/tests/index.test.js b/js/packages/binderhub-client/tests/index.test.js index 04e35685b..6f1ed2408 100644 --- a/js/packages/binderhub-client/tests/index.test.js +++ b/js/packages/binderhub-client/tests/index.test.js @@ -10,7 +10,7 @@ import { parseEventSource, simpleEventSourceServer } from "./utils"; import { readFileSync } from "node:fs"; async function wrapFetch(resource, options) { - /* like fetch, but ignore signal input + /* like fetch, but ignore signal input // abort signal shows up as uncaught in tests, despite working fine */ if (options) { @@ -82,155 +82,6 @@ test("Build URL correctly built from Build Endpoint when used with token", () => ); }); -test("Get full redirect URL with correct token but no path", () => { - const br = new BinderRepository( - "gh/test/test", - new URL("https://test-binder.org/build"), - ); - expect( - br - .getFullRedirectURL( - new URL("https://hub.test-binder.org/user/something"), - "token", - ) - .toString(), - ).toBe("https://hub.test-binder.org/user/something?token=token"); -}); - -test("Get full redirect URL with urlpath", () => { - const br = new BinderRepository( - "gh/test/test", - new URL("https://test-binder.org/build"), - ); - expect( - br - .getFullRedirectURL( - new URL("https://hub.test-binder.org/user/something"), - "token", - "rstudio", - "url", - ) - .toString(), - ).toBe("https://hub.test-binder.org/user/something/rstudio?token=token"); -}); - -test("Get full redirect URL when opening a file with jupyterlab", () => { - const br = new BinderRepository( - "gh/test/test", - new URL("https://test-binder.org/build"), - ); - expect( - br - .getFullRedirectURL( - new URL("https://hub.test-binder.org/user/something"), - "token", - "index.ipynb", - "lab", - ) - .toString(), - ).toBe( - "https://hub.test-binder.org/user/something/doc/tree/index.ipynb?token=token", - ); -}); - -test("Get full redirect URL when opening a file with classic notebook (with file= path)", () => { - const br = new BinderRepository( - "gh/test/test", - new URL("https://test-binder.org/build"), - ); - expect( - br - .getFullRedirectURL( - new URL("https://hub.test-binder.org/user/something"), - "token", - "index.ipynb", - "file", - ) - .toString(), - ).toBe( - "https://hub.test-binder.org/user/something/tree/index.ipynb?token=token", - ); -}); - -test("Get full redirect URL and deal with excessive slashes (with pathType=url)", () => { - const br = new BinderRepository( - "gh/test/test", - new URL("https://test-binder.org/build"), - ); - expect( - br - .getFullRedirectURL( - // Trailing slash should not be preserved here - new URL("https://hub.test-binder.org/user/something/"), - "token", - // Trailing slash should be preserved here, but leading slash should not be repeated - "/rstudio/", - "url", - ) - .toString(), - ).toBe("https://hub.test-binder.org/user/something/rstudio/?token=token"); -}); - -test("Get full redirect URL and deal with excessive slashes (with pathType=lab)", () => { - const br = new BinderRepository( - "gh/test/test", - new URL("https://test-binder.org/build"), - ); - expect( - br - .getFullRedirectURL( - new URL("https://hub.test-binder.org/user/something/"), - "token", - // Both leading and trailing slashes should be gone here. - "/directory/index.ipynb/", - "lab", - ) - .toString(), - ).toBe( - "https://hub.test-binder.org/user/something/doc/tree/directory/index.ipynb?token=token", - ); -}); - -test("Get full redirect URL and deal with missing trailing slash", () => { - const br = new BinderRepository( - "gh/test/test", - new URL("https://test-binder.org/build"), - ); - expect( - br - .getFullRedirectURL( - // Missing trailing slash here should not affect target url - new URL("https://hub.test-binder.org/user/something"), - "token", - "/directory/index.ipynb/", - "lab", - ) - .toString(), - ).toBe( - "https://hub.test-binder.org/user/something/doc/tree/directory/index.ipynb?token=token", - ); -}); - -test("Get full redirect URL and deal with excessive slashes (with pathType=file)", () => { - const br = new BinderRepository( - "gh/test/test", - new URL("https://test-binder.org/build"), - ); - expect( - br - .getFullRedirectURL( - new URL("https://hub.test-binder.org/user/something/"), - "token", - // Both leading and trailing slashes should be gone here. - "/directory/index.ipynb/", - "file", - ) - .toString(), - ).toBe( - "https://hub.test-binder.org/user/something/tree/directory/index.ipynb?token=token", - ); -}); - describe("Iterate over full output from calling the binderhub API", () => { let closeServer, serverUrl; @@ -286,136 +137,3 @@ describe("Invalid eventsource response causes failure", () => { ]); }); }); - -test("Get full redirect URL and deal with query and encoded query (with pathType=url)", () => { - const br = new BinderRepository( - "gh/test/test", - new URL("https://test-binder.org/build"), - ); - expect( - br - .getFullRedirectURL( - new URL("https://hub.test-binder.org/user/something/"), - "token", - // url path here is already url encoded - "endpoint?a=1%2F2&b=3%3F%2F", - "url", - ) - .toString(), - ).toBe( - // url path here is exactly as encoded as passed in - not *double* encoded - "https://hub.test-binder.org/user/something/endpoint?a=1%2F2&b=3%3F%2F&token=token", - ); -}); - -test("Get full redirect URL with nbgitpuller URL", () => { - const br = new BinderRepository( - "gh/test/test", - new URL("https://test-binder.org/build"), - ); - expect( - br - .getFullRedirectURL( - new URL("https://hub.test-binder.org/user/something/"), - "token", - // urlpath is not actually url encoded - note that / is / not %2F - "git-pull?repo=https://github.com/alperyilmaz/jupyterlab-python-intro&urlpath=lab/tree/jupyterlab-python-intro/&branch=master", - "url", - ) - .toString(), - ).toBe( - // generated URL path here *is* url encoded - "https://hub.test-binder.org/user/something/git-pull?repo=https%3A%2F%2Fgithub.com%2Falperyilmaz%2Fjupyterlab-python-intro&urlpath=lab%2Ftree%2Fjupyterlab-python-intro%2F&branch=master&token=token", - ); -}); - -test("Make a shareable URL", () => { - const url = makeShareableBinderURL( - new URL("https://test.binder.org"), - "gh", - "yuvipanda", - "requirements", - ); - expect(url.toString()).toBe( - "https://test.binder.org/v2/gh/yuvipanda/requirements", - ); -}); - -test("Make a shareable path with URL", () => { - const url = makeShareableBinderURL( - new URL("https://test.binder.org"), - "gh", - "yuvipanda", - "requirements", - "url", - "git-pull?repo=https://github.com/alperyilmaz/jupyterlab-python-intro&urlpath=lab/tree/jupyterlab-python-intro/&branch=master", - ); - expect(url.toString()).toBe( - "https://test.binder.org/v2/gh/yuvipanda/requirements?git-pull%3Frepo%3Dhttps%3A%2F%2Fgithub.com%2Falperyilmaz%2Fjupyterlab-python-intro%26urlpath%3Dlab%2Ftree%2Fjupyterlab-python-intro%2F%26branch%3Dmasterpath=url", - ); -}); - -test("Making a shareable URL with base URL without trailing / throws error", () => { - expect(() => { - makeShareableBinderURL( - new URL("https://test.binder.org/suffix"), - "gh", - "yuvipanda", - "requirements", - ); - }).toThrow(Error); -}); - -test("Make a markdown badge", () => { - const url = makeShareableBinderURL( - new URL("https://test.binder.org"), - "gh", - "yuvipanda", - "requirements", - ); - const badge = makeBadgeMarkup( - new URL("https://test.binder.org"), - url, - "markdown", - ); - expect(badge).toBe( - "[![Binder](https://test.binder.org/badge_logo.svg)](https://test.binder.org/v2/gh/yuvipanda/requirements)", - ); -}); - -test("Make a rst badge", () => { - const url = makeShareableBinderURL( - new URL("https://test.binder.org"), - "gh", - "yuvipanda", - "requirements", - ); - const badge = makeBadgeMarkup(new URL("https://test.binder.org"), url, "rst"); - expect(badge).toBe( - ".. image:: https://test.binder.org/badge_logo.svg\n :target: https://test.binder.org/v2/gh/yuvipanda/requirements", - ); -}); - -test("Making a badge with an unsupported syntax throws error", () => { - const url = makeShareableBinderURL( - new URL("https://test.binder.org"), - "gh", - "yuvipanda", - "requirements", - ); - expect(() => { - makeBadgeMarkup(new URL("https://test.binder.org"), url, "docx"); - }).toThrow(Error); -}); - -test("Making a badge with base URL without trailing / throws error", () => { - const url = makeShareableBinderURL( - new URL("https://test.binder.org"), - "gh", - "yuvipanda", - "requirements", - ); - expect(() => { - makeBadgeMarkup(new URL("https://test.binder.org/suffix"), url, "markdown"); - }).toThrow(Error); -}); diff --git a/package.json b/package.json index 916d542cf..f908f64e1 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,17 @@ "name": "binderhub", "description": "BinderHub's web user interface involves javascript built by this node package.", "dependencies": { - "bootstrap": "^3.4.1", + "@fontsource/clear-sans": "^5.0.11", + "@types/react": "^18.3.2", + "bootstrap": "^5.3.3", + "bootstrap-icons": "^1.11.3", "clipboard": "^2.0.11", + "copy-to-clipboard": "^3.3.3", "jquery": "^3.6.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router": "^6.23.1", + "react-router-dom": "^6.23.1", "xterm": "^5.1.0", "xterm-addon-fit": "^0.7.0" }, @@ -13,16 +21,25 @@ "@babel/core": "^7.21.4", "@babel/eslint-parser": "^7.22.15", "@babel/preset-env": "^7.21.4", + "@babel/preset-react": "^7.24.1", "@types/jest": "^29.5.5", "@whatwg-node/fetch": "^0.9.17", + "autoprefixer": "^10.4.19", "babel-jest": "^29.7.0", "babel-loader": "^9.1.2", - "css-loader": "^6.7.3", + "css-loader": "^6.11.0", "eslint": "^8.38.0", "eslint-plugin-jest": "^27.4.2", + "eslint-plugin-react": "^7.34.1", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "mini-css-extract-plugin": "^2.7.5", + "postcss-loader": "^8.1.1", + "sass": "^1.77.1", + "sass-loader": "^14.2.1", + "style-loader": "^4.0.0", + "ts-loader": "^9.5.1", + "typescript": "^5.4.5", "webpack": "^5.78.0", "webpack-cli": "^5.0.1" }, diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..4a56b9984 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": false, + "noEmit": false, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "noImplicitAny": false, + "module": "es6", + "target": "es5", + "jsx": "react-jsx", + "moduleResolution": "node", + "sourceMap": true + }, + "include": ["binderhub/static/js/"] +} diff --git a/webpack.config.js b/webpack.config.js index ba73e76a6..db0d44a93 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,16 +1,17 @@ const webpack = require("webpack"); const path = require("path"); +const autoprefixer = require("autoprefixer"); const MiniCssExtractPlugin = require("mini-css-extract-plugin"); module.exports = { - mode: "production", + mode: "development", context: path.resolve(__dirname, "binderhub/static"), - entry: "./js/index.js", + entry: "./js/App.jsx", output: { path: path.resolve(__dirname, "binderhub/static/dist/"), filename: "bundle.js", - publicPath: "/static/dist/", + publicPath: "auto", }, plugins: [ new webpack.ProvidePlugin({ @@ -21,16 +22,16 @@ module.exports = { filename: "styles.css", }), ], + resolve: { + extensions: [".tsx", ".ts", ".js", ".jsx"], + }, module: { rules: [ { - test: /\.js$/, + test: /\.(t|j)sx?$/, exclude: /(node_modules|bower_components)/, use: { - loader: "babel-loader", - options: { - presets: ["@babel/preset-env"], - }, + loader: "ts-loader", }, }, { @@ -49,7 +50,33 @@ module.exports = { ], }, { - test: /\.(eot|woff|ttf|woff2|svg)$/, + test: /\.(scss)$/, + use: [ + { + // Adds CSS to the DOM by injecting a `