From 722b84d300374232d753549a5d140421273f9513 Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Wed, 4 Dec 2024 16:57:48 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=80=20MVP=20launch=20buttons=20for=20J?= =?UTF-8?q?upyterHub=20&=20MyBinder=20(#503)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip: initial prototype * fix: drop unused deps * fix: improve style * wip: jhub launcher * wip: link building * chore: refactoring * feat: use form * feat: enable copy-to-clipboard * feat: add launch icon * refactor: use state rather than refs * refactor: move to LaunchButton.tsx * fix: don't store invalid link in clip * fix: use project frontmatter * chore: revert style changes * fix: just export types for now * fix: import type * refactor: use project config * fix: bugfixes * fix: small style fixes * docs: update README * fix: styling --- README.md | 16 +- package-lock.json | 327 ++++++++++--- packages/frontmatter/package.json | 5 +- packages/frontmatter/src/FrontmatterBlock.tsx | 10 +- packages/frontmatter/src/LaunchButton.tsx | 456 ++++++++++++++++++ packages/site/package.json | 3 - packages/site/src/pages/Article.tsx | 5 + themes/article/app/components/Article.tsx | 21 +- themes/book/app/components/ArticlePage.tsx | 5 + 9 files changed, 776 insertions(+), 72 deletions(-) create mode 100644 packages/frontmatter/src/LaunchButton.tsx diff --git a/README.md b/README.md index f5b929b6..92ba298b 100644 --- a/README.md +++ b/README.md @@ -91,15 +91,25 @@ To interact with the themes in development mode (e.g. with live-reload of compon 2. the renderer/application (theme) 3. a process watching all components +First, start the theme application: ```bash -# In a directory with content -myst start --headless -# In myst-theme +# Install dependencies +npm install +# First, build the theme +npm run build + +# Then start the theme npm run theme:book # In another terminal, watch for changes and rebuild npm run dev ``` +Then, start the content server application in e.g. the mystmd docs: +```bash +# In a directory with content +myst start --headless +``` + > **Note**: in the future, this repository will likely have it's own content to test out with the themes. > You can currently look to the mystjs/docs folder, or an [article](https://github.com/simpeg/tle-finitevolume) or a [thesis](https://github.com/rowanc1/phd-thesis). diff --git a/package-lock.json b/package-lock.json index 786beb72..a544a3c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8361,6 +8361,34 @@ } } }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.0.tgz", + "integrity": "sha512-1/oVYPDjbFILOLIarcGcMKo+y6SbTVT/iUKVEw59CF4offwZgBgC3ZOeSBewjqU0vdA6FWTPWTN63obj55S/tQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-label": "2.1.0", + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-hover-card": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.1.tgz", @@ -8392,6 +8420,14 @@ } } }, + "node_modules/@radix-ui/react-icons": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", + "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==", + "peerDependencies": { + "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/@radix-ui/react-id": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", @@ -8410,27 +8446,49 @@ } } }, - "node_modules/@radix-ui/react-popover": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.1.tgz", - "integrity": "sha512-3y1A3isulwnWhvTTwmIreiB8CF4L+qRjZnK1wYLO7pplddzXKby/GnZ2M7OZY3qgnl6p9AodUIHRYGXNah8Y7g==", + "node_modules/@radix-ui/react-label": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.0.tgz", + "integrity": "sha512-peLblDlFw/ngk3UWq0VnYaOLy6agTZZ+MUO/WhVfm14vJGML+xH4FAl2XQGLqdefjNb7ApRg6Yn7U42ZhmYXdw==", "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.2.tgz", + "integrity": "sha512-u2HRUyWW+lOiA2g0Le0tMmT55FGOEWHwPFt1EPfbLly7uXQExFo5duNKqG2DzmFXIdqOeNd+TpE8baHWJCyP9w==", "dependencies": { "@radix-ui/primitive": "1.1.0", "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.0", - "@radix-ui/react-focus-guards": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.1", - "@radix-ui/react-presence": "1.1.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", "@radix-ui/react-primitive": "2.0.0", "@radix-ui/react-slot": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0", "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.5.7" + "react-remove-scroll": "2.6.0" }, "peerDependencies": { "@types/react": "*", @@ -8447,22 +8505,30 @@ } } }, - "node_modules/@radix-ui/react-popper": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", - "integrity": "sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==", - "license": "MIT", + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz", + "integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==", "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.0", + "@radix-ui/primitive": "1.1.0", "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", "@radix-ui/react-primitive": "2.0.0", "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0", - "@radix-ui/react-use-rect": "1.1.0", - "@radix-ui/react-use-size": "1.1.0", - "@radix-ui/rect": "1.1.0" + "@radix-ui/react-use-escape-keydown": "1.1.0" }, "peerDependencies": { "@types/react": "*", @@ -8479,11 +8545,24 @@ } } }, - "node_modules/@radix-ui/react-portal": { + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-guards": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.1.tgz", - "integrity": "sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g==", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz", + "integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==", "dependencies": { "@radix-ui/react-primitive": "2.0.0", "@radix-ui/react-use-layout-effect": "1.1.0" @@ -8503,11 +8582,10 @@ } } }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.0.tgz", - "integrity": "sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==", - "license": "MIT", + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-presence": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", + "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", "dependencies": { "@radix-ui/react-compose-refs": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0" @@ -8527,13 +8605,46 @@ } } }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", - "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "node_modules/@radix-ui/react-popover/node_modules/react-remove-scroll": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz", + "integrity": "sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==", + "dependencies": { + "react-remove-scroll-bar": "^2.3.6", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", + "integrity": "sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.1.0" + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" }, "peerDependencies": { "@types/react": "*", @@ -8550,21 +8661,14 @@ } } }, - "node_modules/@radix-ui/react-radio-group": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.0.tgz", - "integrity": "sha512-yv+oiLaicYMBpqgfpSPw6q+RyXlLdIpQWDHZbUKURxe+nEh53hFXPPlfhfQQtYkS5MMK/5IWIa76SksleQZSzw==", + "node_modules/@radix-ui/react-portal": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.1.tgz", + "integrity": "sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g==", + "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-presence": "1.1.0", "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-roving-focus": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-use-previous": "1.1.0", - "@radix-ui/react-use-size": "1.1.0" + "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", @@ -8581,31 +8685,50 @@ } } }, - "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-direction": { + "node_modules/@radix-ui/react-presence": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", - "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.0.tgz", + "integrity": "sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-previous": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz", - "integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==", + "node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, @@ -9216,6 +9339,86 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.1.tgz", + "integrity": "sha512-3GBUDmP2DvzmtYLMsHmpA1GtR46ZDZ+OreXM/N+kkQJOPIgytFWWTfDQmBQKBvaFS0Vno0FktdbVzN28KGrMdw==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", + "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-toggle": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.0.tgz", @@ -41006,7 +41209,10 @@ "dependencies": { "@headlessui/react": "^1.7.15", "@heroicons/react": "^2.0.18", - "@radix-ui/react-popover": "^1.0.6", + "@radix-ui/react-form": "^0.1.0", + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.1", "@scienceicons/react": "^0.0.6", "classnames": "^2.3.2", "myst-common": "*", @@ -41279,9 +41485,6 @@ "@myst-theme/search": "^0.13.4", "@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-dialog": "^1.0.3", - "@radix-ui/react-radio-group": "^1.2.0", - "@radix-ui/react-roving-focus": "^1.1.0", - "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-visually-hidden": "^1.1.0", "classnames": "^2.3.2", "lodash.throttle": "^4.1.1", diff --git a/packages/frontmatter/package.json b/packages/frontmatter/package.json index feff7f5f..e13e719c 100644 --- a/packages/frontmatter/package.json +++ b/packages/frontmatter/package.json @@ -22,7 +22,10 @@ "dependencies": { "@headlessui/react": "^1.7.15", "@heroicons/react": "^2.0.18", - "@radix-ui/react-popover": "^1.0.6", + "@radix-ui/react-form": "^0.1.0", + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.1", "@scienceicons/react": "^0.0.6", "classnames": "^2.3.2", "myst-common": "*", diff --git a/packages/frontmatter/src/FrontmatterBlock.tsx b/packages/frontmatter/src/FrontmatterBlock.tsx index 72f3ff4f..98a04e2a 100644 --- a/packages/frontmatter/src/FrontmatterBlock.tsx +++ b/packages/frontmatter/src/FrontmatterBlock.tsx @@ -1,11 +1,12 @@ import React from 'react'; import classNames from 'classnames'; -import type { PageFrontmatter } from 'myst-frontmatter'; +import type { ExpandedThebeFrontmatter, PageFrontmatter } from 'myst-frontmatter'; import { SourceFileKind } from 'myst-spec-ext'; import { JupyterIcon, OpenAccessIcon, GithubIcon, TwitterIcon } from '@scienceicons/react/24/solid'; import { LicenseBadges } from './licenses.js'; import { DownloadsDropdown } from './downloads.js'; import { AuthorAndAffiliations, AuthorsList } from './Authors.js'; +import { LaunchButton } from './LaunchButton.js'; function ExternalOrInternalLink({ to, @@ -194,6 +195,8 @@ export function FrontmatterBlock({ hideBadges, hideExports, className, + thebe, + location, }: { frontmatter: Omit; kind?: SourceFileKind; @@ -201,6 +204,8 @@ export function FrontmatterBlock({ hideBadges?: boolean; hideExports?: boolean; className?: string; + thebe?: ExpandedThebeFrontmatter; + location?: string; }) { if (!frontmatter) return null; const { @@ -226,6 +231,8 @@ export function FrontmatterBlock({ const hasHeaders = !!subject || !!venue || !!volume || !!issue; const hasDateOrDoi = !!doi || !!date; const showHeaderBlock = hasHeaders || (hasBadges && !hideBadges) || (hasExports && !hideExports); + const hideLaunch: boolean = false; + if (!title && !subtitle && !showHeaderBlock && !hasAuthors && !hasDateOrDoi) { // Nothing to show! return null; @@ -267,6 +274,7 @@ export function FrontmatterBlock({ )} {!hideExports && } + {!hideLaunch && thebe && location && } )} {title &&

{title}

} diff --git a/packages/frontmatter/src/LaunchButton.tsx b/packages/frontmatter/src/LaunchButton.tsx new file mode 100644 index 00000000..707f493f --- /dev/null +++ b/packages/frontmatter/src/LaunchButton.tsx @@ -0,0 +1,456 @@ +import React, { useRef, useCallback, useState } from 'react'; +import classNames from 'classnames'; + +import * as Popover from '@radix-ui/react-popover'; +import { RocketIcon, Cross2Icon, ClipboardCopyIcon, ExternalLinkIcon } from '@radix-ui/react-icons'; +import * as Tabs from '@radix-ui/react-tabs'; +import * as Form from '@radix-ui/react-form'; +import type { ExpandedThebeFrontmatter, BinderHubOptions } from 'myst-frontmatter'; + +const GITHUB_USERNAME_REPO_REGEX = + /^(?:https?:\/\/github.com\/)?([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:.git)?\/?$/; +const GITLAB_USERNAME_REPO_REGEX = + /^(?:https?:\/\/gitlab.com\/)?([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:.git)?\/?$/; +const GIST_USERNAME_REPO_REGEX = + /^(?:https?:\/\/gist.github.com\/)?([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:.git)?\/?$/; + +type CopyButtonProps = { + defaultMessage: string; + alternateMessage?: string; + timeout?: number; + buildLink: () => string | undefined; + className?: string; +}; + +function CopyButton(props: CopyButtonProps) { + const { className, defaultMessage, alternateMessage, buildLink, timeout } = props; + const [message, setMessage] = useState(defaultMessage); + + const copyLink = useCallback(() => { + // Build the link for the clipboard + const link = props.buildLink(); + // In secure links, we can copy it! + if (window.isSecureContext) { + // Write to clipboard + window.navigator.clipboard.writeText(link ?? ''); + // Update UI + setMessage(alternateMessage ?? defaultMessage); + + // Set callback to restore message + setTimeout(() => { + setMessage(defaultMessage); + }, timeout ?? 1000); + } + }, [defaultMessage, alternateMessage, buildLink, timeout, setMessage]); + + return ( + + ); +} + +export type LaunchProps = { + thebe: ExpandedThebeFrontmatter; + location: string; +}; +type ModalLaunchProps = LaunchProps & { + onLaunch?: () => void; +}; + +/** + * Ensure URL of for http://foo.com/bar?baz + * has the form http://foo.com/bar/ + * + * @param url - URL to parse + */ +function ensureBasename(url: string): string { + // Parse input URL (or fallback) + const parsedURL = new URL(url); + // Drop any fragments + let baseURL = `${parsedURL.origin}${parsedURL.pathname}`; + // Ensure a trailing fragment + if (!baseURL.endsWith('/')) { + baseURL = `${baseURL}/`; + } + return baseURL; +} + +/** + * Equivalent to Python's `urllib.parse.urlencode` function + * + * @param params - mapping from parameter name to string value + */ +function encodeURLParams(params: Record): string { + return Object.entries(params) + .filter(([key, value]) => value !== undefined) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value as string)}`) + .join('&'); +} + +/** + * Make a binder url for supported providers + * + * - trim gitlab.com from repo + * - trim trailing or leading '/' on repo + * - convert to URL acceptable string. Required for gitlab + * - trailing / on binderUrl + * + * Copied from thebe-core + * + * @param opts BinderOptions + * @returns a binder compatible url + */ +function makeBinderURL( + options: BinderHubOptions, + location: string, + version: string = 'v2', +): string | undefined { + let stub: string; + + if (!options.repo || !options.url) { + return undefined; + } + + switch (options.provider) { + case 'git': { + const encodedRepo = encodeURIComponent(options.repo); + const encodedRef = encodeURIComponent(options.ref ?? 'HEAD'); + stub = `git/${encodedRepo}/${encodedRef}`; + break; + } + case 'gitlab': { + const [, org, repo] = options.repo.match(GITLAB_USERNAME_REPO_REGEX) ?? []; + if (!org) { + return undefined; + } + const encodedRef = encodeURIComponent(options.ref ?? 'HEAD'); + stub = `gl/${org}/${repo}/${encodedRef}`; + break; + } + case 'github': { + const [, org, repo] = options.repo.match(GITHUB_USERNAME_REPO_REGEX) ?? []; + if (!org) { + return undefined; + } + const encodedRef = encodeURIComponent(options.ref ?? 'HEAD'); + stub = `gh/${org}/${repo}/${encodedRef}`; + break; + } + case 'gist': { + const [, org, repo] = options.repo.match(GIST_USERNAME_REPO_REGEX) ?? []; + if (!org) { + return undefined; + } + const encodedRef = encodeURIComponent(options.ref ?? 'HEAD'); + stub = `gist/${org}/${repo}/${encodedRef}`; + break; + } + default: { + return undefined; + } + } + // Build binder URL path + const query = encodeURLParams({ urlpath: `/lab/tree/${location}` }); + + const binderURL = ensureBasename(options.url); + return `${binderURL}${version}/${stub}?${query}`; +} + +function cloneNameFromRepo(repo: string) { + const url = new URL(repo); + const parts = url.pathname.slice(1).split('/'); + return parts[parts.length - 1] || url.hostname; +} + +/** + * Make an nbgitpuller url for supported providers + * + * - trim gitlab.com from repo + * - trim trailing or leading '/' on repo + * - convert to URL acceptable string. Required for gitlab + * - trailing / on binderUrl + * + * Copied from thebe-core + * + * @param opts BinderOptions + * @returns a binder compatible url + */ +function makeNbgitpullerURL(options: BinderHubOptions, location: string): string | undefined { + if (!options.repo || !options.url) { + return undefined; + } + const { ref } = options; + + let repo: string; + let cloneName: string; + + switch (options.provider) { + case 'git': { + repo = options.repo; + cloneName = cloneNameFromRepo(repo); + break; + } + case 'gitlab': { + const [, org, name] = options.repo.match(GITLAB_USERNAME_REPO_REGEX) ?? []; + repo = `https://gitlab.com/${org}/${name}`; + cloneName = name; + break; + } + case 'github': { + const [, org, name] = options.repo.match(GITHUB_USERNAME_REPO_REGEX) ?? []; + repo = `https://github.com/${org}/${name}`; + cloneName = name; + break; + } + case 'gist': { + const [, , rev] = options.repo.match(GIST_USERNAME_REPO_REGEX) ?? []; + repo = `https://gist.github.com/${rev}`; + cloneName = rev; + break; + } + default: { + return undefined; + } + } + + // Build binder URL path + const query = encodeURLParams({ + repo, + // Need a valid branch name, not a rev + // branch: ref, + urlpath: `/lab/tree/${cloneName}${location}`, + }); + + return `git-pull?${query}`; +} + +function BinderLaunchContent(props: ModalLaunchProps) { + const { thebe, location, onLaunch } = props; + const { binder } = thebe; + const defaultBinderBaseURL = binder?.url ?? 'https://mybinder.org'; + + const formRef = useRef(null); + const buildLink = useCallback(() => { + const form = formRef.current; + if (!form) { + return; + } + + const data = Object.fromEntries(new FormData(form) as any); + return makeBinderURL({ ...(binder ?? {}), url: data.url || defaultBinderBaseURL }, location); + }, [formRef, location, binder]); + + // FIXME: use ValidityState from radix-ui once passing-by-name is fixed + const urlRef = useRef(null); + const buildValidLink = useCallback(() => { + if (urlRef.current?.dataset.invalid === 'true') { + return; + } else { + return buildLink(); + } + }, [buildLink, urlRef]); + + const handleSubmit = useCallback( + (event: React.SyntheticEvent) => { + event.preventDefault(); + + const link = buildLink(); + + // Link should exist, but guard anyway + if (link) { + window?.open(link, '_blank')?.focus(); + } + onLaunch?.(); + }, + [defaultBinderBaseURL, buildLink, onLaunch], + ); + return ( + +

+ Launch on a BinderHub e.g. mybinder.org +

+ +
+ BinderHub URL + + Please provide a valid URL that starts with http(s). + +
+ + + +
+
+ + + + +
+
+ ); +} + +function JupyterHubLaunchContent(props: ModalLaunchProps) { + const { onLaunch, location, thebe } = props; + const { binder } = thebe; + + const defaultHubBaseURL = ''; + + const formRef = useRef(null); + const buildLink = useCallback(() => { + const form = formRef.current; + if (!form) { + return; + } + + const data = Object.fromEntries(new FormData(form) as any); + const rawHubBaseURL = data.url; + if (!rawHubBaseURL) { + return; + } + const gitPullURL = makeNbgitpullerURL(binder ?? {}, location); + const hubURL = ensureBasename(rawHubBaseURL); + return `${hubURL}hub/user-redirect/${gitPullURL}`; + }, [formRef, location, binder]); + + // FIXME: use ValidityState from radix-ui once passing-by-name is fixed + const urlRef = useRef(null); + const buildValidLink = useCallback(() => { + if (urlRef.current?.dataset.invalid === 'true') { + return; + } else { + return buildLink(); + } + }, [buildLink, urlRef]); + + const handleSubmit = useCallback( + (event: React.SyntheticEvent) => { + event.preventDefault(); + + const link = buildLink(); + + // Link should exist, but guard anyway + if (link) { + window?.open(link, '_blank')?.focus(); + } + onLaunch?.(); + }, + [defaultHubBaseURL, buildLink, onLaunch], + ); + + return ( + +

Launch on a JupyterHub

+ +
+ JupyterHub URL + + Please provide a URL. + + + + Please provide a valid URL that starts with http(s). + +
+ + + +
+ +
+ + + + +
+
+ ); +} + +export function LaunchButton(props: LaunchProps) { + const closeRef = useRef(null); + const closePopover = useCallback(() => { + closeRef.current?.click?.(); + }, []); + return ( + + + + + + + + + + Binder + + + JupyterHub + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/site/package.json b/packages/site/package.json index f214e43a..12685192 100644 --- a/packages/site/package.json +++ b/packages/site/package.json @@ -29,9 +29,6 @@ "@myst-theme/search": "^0.13.4", "@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-dialog": "^1.0.3", - "@radix-ui/react-radio-group": "^1.2.0", - "@radix-ui/react-roving-focus": "^1.1.0", - "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-visually-hidden": "^1.1.0", "classnames": "^2.3.2", "lodash.throttle": "^4.1.1", diff --git a/packages/site/src/pages/Article.tsx b/packages/site/src/pages/Article.tsx index af031946..0b5e0439 100644 --- a/packages/site/src/pages/Article.tsx +++ b/packages/site/src/pages/Article.tsx @@ -44,6 +44,9 @@ export const ArticlePage = React.memo(function ({ const keywords = article.frontmatter?.keywords ?? []; const parts = extractKnownParts(tree, article.frontmatter?.parts); + const { thebe } = manifest as any; + const { location } = article; + return ( )} diff --git a/themes/article/app/components/Article.tsx b/themes/article/app/components/Article.tsx index b4c2eeb1..ecdd6a12 100644 --- a/themes/article/app/components/Article.tsx +++ b/themes/article/app/components/Article.tsx @@ -9,9 +9,15 @@ import { extractKnownParts, Footnotes, } from '@myst-theme/site'; +import React from 'react'; import { ErrorTray, NotebookToolbar, useComputeOptions } from '@myst-theme/jupyter'; import { FrontmatterBlock } from '@myst-theme/frontmatter'; -import { ReferencesProvider, useThemeTop, useMediaQuery } from '@myst-theme/providers'; +import { + ReferencesProvider, + useThemeTop, + useMediaQuery, + useProjectManifest, +} from '@myst-theme/providers'; import type { GenericParent } from 'myst-common'; import { copyNode } from 'myst-common'; import { BusyScopeProvider, ConnectionStatusTray, ExecuteScopeProvider } from '@myst-theme/jupyter'; @@ -32,6 +38,7 @@ export function Article({ hideTitle?: boolean; outlineMaxDepth?: number; }) { + const manifest = useProjectManifest(); const keywords = article.frontmatter?.keywords ?? []; const tree = copyNode(article.mdast); const parts = extractKnownParts(tree, article.frontmatter?.parts); @@ -39,6 +46,9 @@ export function Article({ const compute = useComputeOptions(); const top = useThemeTop(); const isOutlineMargin = useMediaQuery('(min-width: 1024px)'); + + const { thebe } = manifest as any; + const { location } = article; return ( - {!hideTitle && } + {!hideTitle && ( + + )} {!hideOutline && (
)} {!hide_outline && (