From 0eed818e957383ffb2ca1b5fefcd9fbe7749e411 Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Mon, 25 Nov 2024 16:21:16 +0000 Subject: [PATCH 01/16] wip: initial prototype --- package-lock.json | 154 ++++++++++++++++-- packages/frontmatter/package.json | 3 +- packages/frontmatter/src/FrontmatterBlock.tsx | 85 +++++++++- packages/site/src/pages/Article.tsx | 1 + themes/article/app/components/Article.tsx | 8 +- themes/book/app/components/ArticlePage.tsx | 1 + 6 files changed, 238 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 786beb72..28f673ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8392,6 +8392,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", @@ -8411,26 +8419,102 @@ } }, "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==", - "license": "MIT", + "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": "*", + "@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/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": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "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-popover/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.1", + "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" }, "peerDependencies": { "@types/react": "*", @@ -8447,6 +8531,53 @@ } } }, + "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" + }, + "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/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", @@ -41006,7 +41137,8 @@ "dependencies": { "@headlessui/react": "^1.7.15", "@heroicons/react": "^2.0.18", - "@radix-ui/react-popover": "^1.0.6", + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-popover": "^1.1.2", "@scienceicons/react": "^0.0.6", "classnames": "^2.3.2", "myst-common": "*", diff --git a/packages/frontmatter/package.json b/packages/frontmatter/package.json index feff7f5f..9bb23730 100644 --- a/packages/frontmatter/package.json +++ b/packages/frontmatter/package.json @@ -22,7 +22,8 @@ "dependencies": { "@headlessui/react": "^1.7.15", "@heroicons/react": "^2.0.18", - "@radix-ui/react-popover": "^1.0.6", + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-popover": "^1.1.2", "@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..2c889622 100644 --- a/packages/frontmatter/src/FrontmatterBlock.tsx +++ b/packages/frontmatter/src/FrontmatterBlock.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo, useCallback } from 'react'; import classNames from 'classnames'; import type { PageFrontmatter } from 'myst-frontmatter'; import { SourceFileKind } from 'myst-spec-ext'; @@ -6,6 +6,8 @@ import { JupyterIcon, OpenAccessIcon, GithubIcon, TwitterIcon } from '@scienceic import { LicenseBadges } from './licenses.js'; import { DownloadsDropdown } from './downloads.js'; import { AuthorAndAffiliations, AuthorsList } from './Authors.js'; +import * as Popover from '@radix-ui/react-popover'; +import { RocketIcon, Cross2Icon } from '@radix-ui/react-icons'; function ExternalOrInternalLink({ to, @@ -187,6 +189,76 @@ export function Journal({ ); } +function LaunchButton(props: { github: string; location: string; binder?: string }) { + const binder = props.binder ?? 'https://mybinder.org'; + + const repoExpr = new RegExp('(?:https?://github.com/)([^/]+/[^/]+).*'); + const github = props.github.match(repoExpr)![1]; + + const launchOnBinder = useCallback(() => { + const url = new URL(binder); + + if (!url.pathname.endsWith('/')) { + url.pathname = `${url.pathname}/`; + } + url.pathname = `${url.pathname}v2/gh/${github}/HEAD`; // TODO: SHA + const component = encodeURIComponent(props.location); + url.search = `?labpath=${component}`; + window?.open(url, '_blank')?.focus(); + }, [binder]); + return ( + + + + + + +
+

+ Launch Externally +

+
+ + +
+
+ + + +
+
+ + + + +
+
+
+ ); +} + export function FrontmatterBlock({ frontmatter, kind = SourceFileKind.Article, @@ -194,6 +266,7 @@ export function FrontmatterBlock({ hideBadges, hideExports, className, + location, }: { frontmatter: Omit; kind?: SourceFileKind; @@ -201,6 +274,7 @@ export function FrontmatterBlock({ hideBadges?: boolean; hideExports?: boolean; className?: string; + location?: string; }) { if (!frontmatter) return null; const { @@ -226,6 +300,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 +343,13 @@ export function FrontmatterBlock({ )} {!hideExports && } + {!hideLaunch && frontmatter.github && location && ( + + )} )} {title &&

{title}

} diff --git a/packages/site/src/pages/Article.tsx b/packages/site/src/pages/Article.tsx index af031946..ad47b152 100644 --- a/packages/site/src/pages/Article.tsx +++ b/packages/site/src/pages/Article.tsx @@ -54,6 +54,7 @@ export const ArticlePage = React.memo(function ({ {!hide_title_block && ( diff --git a/themes/article/app/components/Article.tsx b/themes/article/app/components/Article.tsx index b4c2eeb1..b4408888 100644 --- a/themes/article/app/components/Article.tsx +++ b/themes/article/app/components/Article.tsx @@ -46,7 +46,13 @@ export function Article({ > - {!hideTitle && } + {!hideTitle && ( + + )} {!hideOutline && (
From 05abe2d8170b7e48b0de5a985dc81b7d5fd61e3c Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Tue, 26 Nov 2024 10:50:19 +0000 Subject: [PATCH 02/16] fix: drop unused deps --- packages/site/package.json | 3 --- 1 file changed, 3 deletions(-) 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", From 9eafa2fec9b585ab7da146329defb3fd2fd35aa7 Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Tue, 26 Nov 2024 11:45:44 +0000 Subject: [PATCH 03/16] fix: improve style --- packages/frontmatter/src/FrontmatterBlock.tsx | 67 ++++++++++++------- 1 file changed, 43 insertions(+), 24 deletions(-) diff --git a/packages/frontmatter/src/FrontmatterBlock.tsx b/packages/frontmatter/src/FrontmatterBlock.tsx index 2c889622..5a2dfd76 100644 --- a/packages/frontmatter/src/FrontmatterBlock.tsx +++ b/packages/frontmatter/src/FrontmatterBlock.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useCallback } from 'react'; +import React, { useRef, useCallback } from 'react'; import classNames from 'classnames'; import type { PageFrontmatter } from 'myst-frontmatter'; import { SourceFileKind } from 'myst-spec-ext'; @@ -189,28 +189,48 @@ export function Journal({ ); } -function LaunchButton(props: { github: string; location: string; binder?: string }) { - const binder = props.binder ?? 'https://mybinder.org'; +export function LaunchButton(props: { + github: string; + location: string; + binder?: string; + ref?: string; +}) { + // Ensure Binder link ends in / + const defaultBinderBaseURL = props.binder ?? 'https://mybinder.org'; - const repoExpr = new RegExp('(?:https?://github.com/)([^/]+/[^/]+).*'); - const github = props.github.match(repoExpr)![1]; + // Determine Git ref + // TODO: pull this from frontmatter + const refComponent = encodeURIComponent(props.ref ?? 'HEAD'); + const locationComponent = encodeURIComponent(props.location); - const launchOnBinder = useCallback(() => { - const url = new URL(binder); + // Parse the repo, assume it is a validated GitHub URL + const repo = new RegExp('https://github.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)'); + const githubComponent = props.github.match(repo)![1]; + + const binderInputRef = useRef(null); - if (!url.pathname.endsWith('/')) { - url.pathname = `${url.pathname}/`; + const launchOnBinder = useCallback(() => { + // Parse input URL (or fallback) + const parsedBinderBaseURL = new URL(binderInputRef.current?.value ?? defaultBinderBaseURL); + // Drop any fragments + let binderBaseURL = `${parsedBinderBaseURL.origin}${parsedBinderBaseURL.pathname}`; + // Ensure a trailing fragment + if (!binderBaseURL.endsWith('/')) { + binderBaseURL = `${binderBaseURL}/`; } - url.pathname = `${url.pathname}v2/gh/${github}/HEAD`; // TODO: SHA - const component = encodeURIComponent(props.location); - url.search = `?labpath=${component}`; - window?.open(url, '_blank')?.focus(); - }, [binder]); + // Build Binder URL + const binderURL = new URL(binderBaseURL); + binderURL.pathname = `${binderURL.pathname}v2/gh/${githubComponent}/${refComponent}`; + binderURL.search = `?labpath=${locationComponent}`; + + window?.open(binderURL, '_blank')?.focus(); + }, [defaultBinderBaseURL, binderInputRef, githubComponent, refComponent, locationComponent]); + return (
From f7bb066167aded3835e07fb2c25dd5ebf13d0291 Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Thu, 28 Nov 2024 08:00:51 +0000 Subject: [PATCH 04/16] wip: jhub launcher --- package-lock.json | 143 ++++++++------- packages/frontmatter/package.json | 1 + packages/frontmatter/src/FrontmatterBlock.tsx | 167 +++++++++++++++--- 3 files changed, 221 insertions(+), 90 deletions(-) diff --git a/package-lock.json b/package-lock.json index 28f673ef..25a1edcb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8681,65 +8681,6 @@ } } }, - "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==", - "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" - }, - "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-radio-group/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-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==", - "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-roving-focus": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", @@ -9347,6 +9288,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", @@ -41139,6 +41160,7 @@ "@heroicons/react": "^2.0.18", "@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": "*", @@ -41411,9 +41433,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 9bb23730..ffbf1a59 100644 --- a/packages/frontmatter/package.json +++ b/packages/frontmatter/package.json @@ -24,6 +24,7 @@ "@heroicons/react": "^2.0.18", "@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 5a2dfd76..e2433885 100644 --- a/packages/frontmatter/src/FrontmatterBlock.tsx +++ b/packages/frontmatter/src/FrontmatterBlock.tsx @@ -8,6 +8,7 @@ import { DownloadsDropdown } from './downloads.js'; import { AuthorAndAffiliations, AuthorsList } from './Authors.js'; import * as Popover from '@radix-ui/react-popover'; import { RocketIcon, Cross2Icon } from '@radix-ui/react-icons'; +import * as Tabs from '@radix-ui/react-tabs'; function ExternalOrInternalLink({ to, @@ -189,12 +190,21 @@ export function Journal({ ); } -export function LaunchButton(props: { +type CommonLaunchProps = { github: string; location: string; - binder?: string; ref?: string; -}) { +}; + +type JupyterHubLaunchProps = CommonLaunchProps & { + jupyterhub?: string; +}; + +type BinderLaunchProps = CommonLaunchProps & { + binder?: string; +}; + +function BinderLaunchContent(props: BinderLaunchProps) { // Ensure Binder link ends in / const defaultBinderBaseURL = props.binder ?? 'https://mybinder.org'; @@ -226,12 +236,106 @@ export function LaunchButton(props: { window?.open(binderURL, '_blank')?.focus(); }, [defaultBinderBaseURL, binderInputRef, githubComponent, refComponent, locationComponent]); + return ( +
+

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

+
+ + +
+
+ + + +
+
+ ); +} +function JupyterHubLaunchContent(props: JupyterHubLaunchProps) { + // Ensure Binder link ends in / + const defaultHubBaseURL = props.jupyterhub ?? ''; + + // Determine Git ref + // TODO: pull this from frontmatter + const refComponent = encodeURIComponent(props.ref ?? 'HEAD'); + const locationComponent = encodeURIComponent(props.location); + + // Parse the repo, assume it is a validated GitHub URL + const repo = new RegExp('https://github.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)'); + const githubComponent = props.github.match(repo)![1]; + + const hubInputRef = useRef(null); + + const launchOnBinder = useCallback(() => { + // Parse input URL (or fallback) + const rawHubBaseURL = hubInputRef.current?.value; + if (!rawHubBaseURL) { + return; + } + // Drop any fragments + const parsedHubBaseURL = new URL(rawHubBaseURL); + let hubBaseURL = `${parsedHubBaseURL.origin}${parsedHubBaseURL.pathname}`; + // Ensure a trailing fragment + if (!hubBaseURL.endsWith('/')) { + hubBaseURL = `${hubBaseURL}/`; + } + // Build Binder URL + const binderURL = new URL(hubBaseURL); + binderURL.pathname = `${binderURL.pathname}v2/gh/${githubComponent}/${refComponent}`; + binderURL.search = `?labpath=${locationComponent}`; + + // window?.open(binderURL, '_blank')?.focus(); + }, [defaultHubBaseURL, hubInputRef, githubComponent, refComponent, locationComponent]); + + return ( +
+

Launch on a JupyterHub

+
+ + +
+
+ + + +
+
+ ); +} + +export function LaunchButton(props: BinderLaunchProps | JupyterHubLaunchProps) { return ( @@ -241,30 +345,37 @@ export function LaunchButton(props: { className="z-30 text-gray-700 dark:text-white bg-white dark:bg-stone-800 p-5 rounded shadow-[0_10px_38px_-10px_hsla(206,22%,7%,.35),0_10px_20px_-15px_hsla(206,22%,7%,.2)]" sideOffset={5} > -
-

Launch Externally

-
- - -
-
- - - -
-
+ + + + Binder + + + JupyterHub + + + + + + + + + Date: Thu, 28 Nov 2024 12:44:14 +0000 Subject: [PATCH 05/16] wip: link building --- packages/frontmatter/src/FrontmatterBlock.tsx | 86 ++++++++++++++----- 1 file changed, 63 insertions(+), 23 deletions(-) diff --git a/packages/frontmatter/src/FrontmatterBlock.tsx b/packages/frontmatter/src/FrontmatterBlock.tsx index e2433885..73d63114 100644 --- a/packages/frontmatter/src/FrontmatterBlock.tsx +++ b/packages/frontmatter/src/FrontmatterBlock.tsx @@ -191,7 +191,7 @@ export function Journal({ } type CommonLaunchProps = { - github: string; + git: string; location: string; ref?: string; }; @@ -204,6 +204,26 @@ type BinderLaunchProps = CommonLaunchProps & { binder?: string; }; +const GITHUB_PATTERN = /https:\/\/github.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)/; + +type GitResource = { + org: string; + repo: string; + provider: 'github'; +}; + +function parseKnownGitProvider(git: string): GitResource | undefined { + let match; + if ((match = git.match(GITHUB_PATTERN))) { + return { + provider: 'github', + org: match[1], + repo: match[2], + }; + } + return undefined; +} + function BinderLaunchContent(props: BinderLaunchProps) { // Ensure Binder link ends in / const defaultBinderBaseURL = props.binder ?? 'https://mybinder.org'; @@ -211,12 +231,21 @@ function BinderLaunchContent(props: BinderLaunchProps) { // Determine Git ref // TODO: pull this from frontmatter const refComponent = encodeURIComponent(props.ref ?? 'HEAD'); - const locationComponent = encodeURIComponent(props.location); + const locationComponent = encodeURIComponent(`/lab/tree/${props.location}`); // Parse the repo, assume it is a validated GitHub URL - const repo = new RegExp('https://github.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)'); - const githubComponent = props.github.match(repo)![1]; - + let gitComponent: string; + const resource = parseKnownGitProvider(props.git); + switch (resource?.provider) { + case 'github': { + gitComponent = `gh/${resource.org}/${resource.repo}`; + break; + } + default: { + const escapedURL = encodeURIComponent(props.git); + gitComponent = `git/${escapedURL}`; + } + } const binderInputRef = useRef(null); const launchOnBinder = useCallback(() => { @@ -230,11 +259,11 @@ function BinderLaunchContent(props: BinderLaunchProps) { } // Build Binder URL const binderURL = new URL(binderBaseURL); - binderURL.pathname = `${binderURL.pathname}v2/gh/${githubComponent}/${refComponent}`; - binderURL.search = `?labpath=${locationComponent}`; + binderURL.pathname = `${binderURL.pathname}v2/${gitComponent}/${refComponent}`; + binderURL.search = `?urlpath=${locationComponent}`; window?.open(binderURL, '_blank')?.focus(); - }, [defaultBinderBaseURL, binderInputRef, githubComponent, refComponent, locationComponent]); + }, [defaultBinderBaseURL, binderInputRef, gitComponent, refComponent, locationComponent]); return (
@@ -271,18 +300,29 @@ function JupyterHubLaunchContent(props: JupyterHubLaunchProps) { // Determine Git ref // TODO: pull this from frontmatter - const refComponent = encodeURIComponent(props.ref ?? 'HEAD'); - const locationComponent = encodeURIComponent(props.location); + const hubInputRef = useRef(null); - // Parse the repo, assume it is a validated GitHub URL - const repo = new RegExp('https://github.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)'); - const githubComponent = props.github.match(repo)![1]; + const resource = parseKnownGitProvider(props.git); + let urlPath = 'lab/tree'; + switch (resource?.provider) { + case 'github': { + urlPath = `${urlPath}/${resource.repo}${props.location}`; + } + } - const hubInputRef = useRef(null); + const pullQuery = Object.entries({ + repo: props.git, + urlpath: urlPath, + branch: props.ref, // TODO master/main? + }) + .filter(([key, value]) => value !== undefined) + .map(([key, value]) => `${key}=${encodeURIComponent(value as string)}`) + .join('&'); - const launchOnBinder = useCallback(() => { + const launchOnJupyterHub = useCallback(() => { // Parse input URL (or fallback) const rawHubBaseURL = hubInputRef.current?.value; + console.log(rawHubBaseURL); if (!rawHubBaseURL) { return; } @@ -294,19 +334,19 @@ function JupyterHubLaunchContent(props: JupyterHubLaunchProps) { hubBaseURL = `${hubBaseURL}/`; } // Build Binder URL - const binderURL = new URL(hubBaseURL); - binderURL.pathname = `${binderURL.pathname}v2/gh/${githubComponent}/${refComponent}`; - binderURL.search = `?labpath=${locationComponent}`; + const hubURL = new URL(hubBaseURL); + hubURL.pathname = `${hubURL.pathname}hub/user-redirect/git-pull`; + hubURL.search = `?${pullQuery}`; - // window?.open(binderURL, '_blank')?.focus(); - }, [defaultHubBaseURL, hubInputRef, githubComponent, refComponent, locationComponent]); + window?.open(hubURL, '_blank')?.focus(); + }, [defaultHubBaseURL, hubInputRef, pullQuery]); return (

Launch on a JupyterHub

@@ -475,7 +515,7 @@ export function FrontmatterBlock({ {!hideExports && } {!hideLaunch && frontmatter.github && location && ( From d46817e2f03e95546cf7c14be2fc7290a8953fd2 Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Fri, 29 Nov 2024 15:17:18 +0000 Subject: [PATCH 06/16] chore: refactoring --- packages/frontmatter/src/FrontmatterBlock.tsx | 82 +++++++++++-------- 1 file changed, 50 insertions(+), 32 deletions(-) diff --git a/packages/frontmatter/src/FrontmatterBlock.tsx b/packages/frontmatter/src/FrontmatterBlock.tsx index 73d63114..22a5f441 100644 --- a/packages/frontmatter/src/FrontmatterBlock.tsx +++ b/packages/frontmatter/src/FrontmatterBlock.tsx @@ -207,11 +207,18 @@ type BinderLaunchProps = CommonLaunchProps & { const GITHUB_PATTERN = /https:\/\/github.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)/; type GitResource = { + // Provider + provider: 'github'; + // Per-provider info org: string; repo: string; - provider: 'github'; }; +/** + * Parse a Git source URL into a Git "resource" consisting of a provider and provider info + * + * @param git - git URL + */ function parseKnownGitProvider(git: string): GitResource | undefined { let match; if ((match = git.match(GITHUB_PATTERN))) { @@ -224,6 +231,36 @@ function parseKnownGitProvider(git: string): GitResource | undefined { return undefined; } +/** + * 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): URL { + // 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 new URL(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('&'); +} + function BinderLaunchContent(props: BinderLaunchProps) { // Ensure Binder link ends in / const defaultBinderBaseURL = props.binder ?? 'https://mybinder.org'; @@ -231,7 +268,8 @@ function BinderLaunchContent(props: BinderLaunchProps) { // Determine Git ref // TODO: pull this from frontmatter const refComponent = encodeURIComponent(props.ref ?? 'HEAD'); - const locationComponent = encodeURIComponent(`/lab/tree/${props.location}`); + + const query = encodeURLParams({ urlpath: `/lab/tree/${props.location}` }); // Parse the repo, assume it is a validated GitHub URL let gitComponent: string; @@ -249,21 +287,12 @@ function BinderLaunchContent(props: BinderLaunchProps) { const binderInputRef = useRef(null); const launchOnBinder = useCallback(() => { - // Parse input URL (or fallback) - const parsedBinderBaseURL = new URL(binderInputRef.current?.value ?? defaultBinderBaseURL); - // Drop any fragments - let binderBaseURL = `${parsedBinderBaseURL.origin}${parsedBinderBaseURL.pathname}`; - // Ensure a trailing fragment - if (!binderBaseURL.endsWith('/')) { - binderBaseURL = `${binderBaseURL}/`; - } - // Build Binder URL - const binderURL = new URL(binderBaseURL); + const binderURL = ensureBasename(binderInputRef.current?.value || defaultBinderBaseURL); binderURL.pathname = `${binderURL.pathname}v2/${gitComponent}/${refComponent}`; - binderURL.search = `?urlpath=${locationComponent}`; + binderURL.search = `?${query}`; window?.open(binderURL, '_blank')?.focus(); - }, [defaultBinderBaseURL, binderInputRef, gitComponent, refComponent, locationComponent]); + }, [defaultBinderBaseURL, binderInputRef, gitComponent, refComponent, query]); return (
@@ -294,6 +323,7 @@ function BinderLaunchContent(props: BinderLaunchProps) {
); } + function JupyterHubLaunchContent(props: JupyterHubLaunchProps) { // Ensure Binder link ends in / const defaultHubBaseURL = props.jupyterhub ?? ''; @@ -310,36 +340,24 @@ function JupyterHubLaunchContent(props: JupyterHubLaunchProps) { } } - const pullQuery = Object.entries({ + const query = encodeURLParams({ repo: props.git, urlpath: urlPath, branch: props.ref, // TODO master/main? - }) - .filter(([key, value]) => value !== undefined) - .map(([key, value]) => `${key}=${encodeURIComponent(value as string)}`) - .join('&'); + }); const launchOnJupyterHub = useCallback(() => { // Parse input URL (or fallback) const rawHubBaseURL = hubInputRef.current?.value; - console.log(rawHubBaseURL); if (!rawHubBaseURL) { return; } - // Drop any fragments - const parsedHubBaseURL = new URL(rawHubBaseURL); - let hubBaseURL = `${parsedHubBaseURL.origin}${parsedHubBaseURL.pathname}`; - // Ensure a trailing fragment - if (!hubBaseURL.endsWith('/')) { - hubBaseURL = `${hubBaseURL}/`; - } - // Build Binder URL - const hubURL = new URL(hubBaseURL); + const hubURL = ensureBasename(rawHubBaseURL); hubURL.pathname = `${hubURL.pathname}hub/user-redirect/git-pull`; - hubURL.search = `?${pullQuery}`; + hubURL.search = `?${query}`; window?.open(hubURL, '_blank')?.focus(); - }, [defaultHubBaseURL, hubInputRef, pullQuery]); + }, [defaultHubBaseURL, hubInputRef, query]); return (
@@ -369,7 +387,7 @@ function JupyterHubLaunchContent(props: JupyterHubLaunchProps) { ); } -export function LaunchButton(props: BinderLaunchProps | JupyterHubLaunchProps) { +function LaunchButton(props: BinderLaunchProps | JupyterHubLaunchProps) { return ( From e2f97b837516c114bd589c22d6da185619977a5d Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Fri, 29 Nov 2024 17:32:37 +0000 Subject: [PATCH 07/16] feat: use form --- package-lock.json | 52 +++++ packages/frontmatter/package.json | 1 + packages/frontmatter/src/FrontmatterBlock.tsx | 177 ++++++++++-------- 3 files changed, 149 insertions(+), 81 deletions(-) diff --git a/package-lock.json b/package-lock.json index 25a1edcb..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", @@ -8418,6 +8446,29 @@ } } }, + "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", @@ -41158,6 +41209,7 @@ "dependencies": { "@headlessui/react": "^1.7.15", "@heroicons/react": "^2.0.18", + "@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", diff --git a/packages/frontmatter/package.json b/packages/frontmatter/package.json index ffbf1a59..e13e719c 100644 --- a/packages/frontmatter/package.json +++ b/packages/frontmatter/package.json @@ -22,6 +22,7 @@ "dependencies": { "@headlessui/react": "^1.7.15", "@heroicons/react": "^2.0.18", + "@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", diff --git a/packages/frontmatter/src/FrontmatterBlock.tsx b/packages/frontmatter/src/FrontmatterBlock.tsx index 22a5f441..b4edf0f0 100644 --- a/packages/frontmatter/src/FrontmatterBlock.tsx +++ b/packages/frontmatter/src/FrontmatterBlock.tsx @@ -9,6 +9,7 @@ import { AuthorAndAffiliations, AuthorsList } from './Authors.js'; import * as Popover from '@radix-ui/react-popover'; import { RocketIcon, Cross2Icon } from '@radix-ui/react-icons'; import * as Tabs from '@radix-ui/react-tabs'; +import * as Form from '@radix-ui/react-form'; function ExternalOrInternalLink({ to, @@ -194,6 +195,7 @@ type CommonLaunchProps = { git: string; location: string; ref?: string; + onLaunch?: () => void; }; type JupyterHubLaunchProps = CommonLaunchProps & { @@ -284,43 +286,49 @@ function BinderLaunchContent(props: BinderLaunchProps) { gitComponent = `git/${escapedURL}`; } } - const binderInputRef = useRef(null); - const launchOnBinder = useCallback(() => { - const binderURL = ensureBasename(binderInputRef.current?.value || defaultBinderBaseURL); - binderURL.pathname = `${binderURL.pathname}v2/${gitComponent}/${refComponent}`; - binderURL.search = `?${query}`; + const handleSubmit = useCallback( + (event: React.SyntheticEvent) => { + event.preventDefault(); - window?.open(binderURL, '_blank')?.focus(); - }, [defaultBinderBaseURL, binderInputRef, gitComponent, refComponent, query]); + const data = Object.fromEntries(new FormData(event.currentTarget) as any); + + const binderURL = ensureBasename(data.url || defaultBinderBaseURL); + binderURL.pathname = `${binderURL.pathname}v2/${gitComponent}/${refComponent}`; + binderURL.search = `?${query}`; + + window?.open(binderURL, '_blank')?.focus(); + props.onLaunch?.(); + }, + [defaultBinderBaseURL, gitComponent, refComponent, query], + ); return ( -
+

Launch on a BinderHub e.g. mybinder.org

-
- - -
-
- - - -
-
+ +
+ Binder URL + + Please provide a valid URL + +
+ + + +
+ + + + ); } @@ -328,10 +336,6 @@ function JupyterHubLaunchContent(props: JupyterHubLaunchProps) { // Ensure Binder link ends in / const defaultHubBaseURL = props.jupyterhub ?? ''; - // Determine Git ref - // TODO: pull this from frontmatter - const hubInputRef = useRef(null); - const resource = parseKnownGitProvider(props.git); let urlPath = 'lab/tree'; switch (resource?.provider) { @@ -346,48 +350,64 @@ function JupyterHubLaunchContent(props: JupyterHubLaunchProps) { branch: props.ref, // TODO master/main? }); - const launchOnJupyterHub = useCallback(() => { - // Parse input URL (or fallback) - const rawHubBaseURL = hubInputRef.current?.value; - if (!rawHubBaseURL) { - return; - } - const hubURL = ensureBasename(rawHubBaseURL); - hubURL.pathname = `${hubURL.pathname}hub/user-redirect/git-pull`; - hubURL.search = `?${query}`; - - window?.open(hubURL, '_blank')?.focus(); - }, [defaultHubBaseURL, hubInputRef, query]); + const handleSubmit = useCallback( + (event: React.SyntheticEvent) => { + event.preventDefault(); + + const data = Object.fromEntries(new FormData(event.currentTarget) as any); + + // Parse input URL (or fallback) + console.dir(data); + const rawHubBaseURL = data.url; + if (!rawHubBaseURL) { + return; + } + const hubURL = ensureBasename(rawHubBaseURL); + hubURL.pathname = `${hubURL.pathname}hub/user-redirect/git-pull`; + hubURL.search = `?${query}`; + + window?.open(hubURL, '_blank')?.focus(); + props.onLaunch?.(); + }, + [defaultHubBaseURL, query], + ); return ( -
+

Launch on a JupyterHub

-
- - -
-
- - - -
-
+ +
+ JupyterHub URL + + Please enter a URL + + + + Please provide a valid URL + +
+ + + +
+ + + + ); } function LaunchButton(props: BinderLaunchProps | JupyterHubLaunchProps) { + const closeRef = useRef(null); + const closePopover = useCallback(() => { + closeRef.current?.click?.(); + }, []); return ( @@ -409,34 +429,29 @@ function LaunchButton(props: BinderLaunchProps | JupyterHubLaunchProps) { aria-label="Launch into computing interface" > Binder JupyterHub - - + + - - + + From ab48ea223693a1f1f8da8305daf8793d6960f53d Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Mon, 2 Dec 2024 16:50:45 +0000 Subject: [PATCH 08/16] feat: enable copy-to-clipboard --- packages/frontmatter/src/FrontmatterBlock.tsx | 160 ++++++++++++++---- 1 file changed, 124 insertions(+), 36 deletions(-) diff --git a/packages/frontmatter/src/FrontmatterBlock.tsx b/packages/frontmatter/src/FrontmatterBlock.tsx index b4edf0f0..9a192778 100644 --- a/packages/frontmatter/src/FrontmatterBlock.tsx +++ b/packages/frontmatter/src/FrontmatterBlock.tsx @@ -7,7 +7,7 @@ import { LicenseBadges } from './licenses.js'; import { DownloadsDropdown } from './downloads.js'; import { AuthorAndAffiliations, AuthorsList } from './Authors.js'; import * as Popover from '@radix-ui/react-popover'; -import { RocketIcon, Cross2Icon } from '@radix-ui/react-icons'; +import { RocketIcon, Cross2Icon, ClipboardCopyIcon } from '@radix-ui/react-icons'; import * as Tabs from '@radix-ui/react-tabs'; import * as Form from '@radix-ui/react-form'; @@ -263,14 +263,53 @@ function encodeURLParams(params: Record): string { .join('&'); } +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 messageRef = useRef(null); + + const copyLink = useCallback(() => { + const link = props.buildLink(); + if (window.isSecureContext && link) { + window.navigator.clipboard.writeText(link); + + const messageBox = messageRef.current; + if (messageBox) { + messageBox.innerText = alternateMessage ?? defaultMessage; + setTimeout(() => { + messageBox.innerText = defaultMessage; + }, timeout ?? 1000); + } + } + }, [messageRef, defaultMessage, alternateMessage, buildLink, timeout]); + return ( + + ); +} + function BinderLaunchContent(props: BinderLaunchProps) { - // Ensure Binder link ends in / + const { onLaunch } = props; const defaultBinderBaseURL = props.binder ?? 'https://mybinder.org'; // Determine Git ref // TODO: pull this from frontmatter const refComponent = encodeURIComponent(props.ref ?? 'HEAD'); + // Build binder URL path const query = encodeURLParams({ urlpath: `/lab/tree/${props.location}` }); // Parse the repo, assume it is a validated GitHub URL @@ -287,24 +326,40 @@ function BinderLaunchContent(props: BinderLaunchProps) { } } + const formRef = useRef(null); + + const buildLink = useCallback(() => { + const form = formRef.current; + if (!form) { + return; + } + + const data = Object.fromEntries(new FormData(form) as any); + + const binderURL = ensureBasename(data.url || defaultBinderBaseURL); + binderURL.pathname = `${binderURL.pathname}v2/${gitComponent}/${refComponent}`; + binderURL.search = `?${query}`; + + return binderURL.toString(); + }, [formRef, gitComponent, refComponent, query]); + const handleSubmit = useCallback( (event: React.SyntheticEvent) => { event.preventDefault(); - const data = Object.fromEntries(new FormData(event.currentTarget) as any); - - const binderURL = ensureBasename(data.url || defaultBinderBaseURL); - binderURL.pathname = `${binderURL.pathname}v2/${gitComponent}/${refComponent}`; - binderURL.search = `?${query}`; + const link = buildLink(); - window?.open(binderURL, '_blank')?.focus(); - props.onLaunch?.(); + // Link should exist, but guard anyway + if (link) { + window?.open(link, '_blank')?.focus(); + } + onLaunch?.(); }, - [defaultBinderBaseURL, gitComponent, refComponent, query], + [defaultBinderBaseURL, buildLink, onLaunch], ); return ( - +

Launch on a BinderHub e.g. mybinder.org

@@ -323,20 +378,29 @@ function BinderLaunchContent(props: BinderLaunchProps) { /> - - - +
+ + + + +
); } function JupyterHubLaunchContent(props: JupyterHubLaunchProps) { - // Ensure Binder link ends in / + const { onLaunch } = props; const defaultHubBaseURL = props.jupyterhub ?? ''; const resource = parseKnownGitProvider(props.git); + let urlPath = 'lab/tree'; switch (resource?.provider) { case 'github': { @@ -344,36 +408,51 @@ function JupyterHubLaunchContent(props: JupyterHubLaunchProps) { } } + // Encode query for nbgitpuller const query = encodeURLParams({ repo: props.git, urlpath: urlPath, branch: props.ref, // TODO master/main? }); + 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 hubURL = ensureBasename(rawHubBaseURL); + hubURL.pathname = `${hubURL.pathname}hub/user-redirect/git-pull`; + hubURL.search = `?${query}`; + + return hubURL.toString(); + }, [formRef, query]); + const handleSubmit = useCallback( (event: React.SyntheticEvent) => { event.preventDefault(); - const data = Object.fromEntries(new FormData(event.currentTarget) as any); + const link = buildLink(); - // Parse input URL (or fallback) - console.dir(data); - const rawHubBaseURL = data.url; - if (!rawHubBaseURL) { - return; + // Link should exist, but guard anyway + if (link) { + window?.open(link, '_blank')?.focus(); } - const hubURL = ensureBasename(rawHubBaseURL); - hubURL.pathname = `${hubURL.pathname}hub/user-redirect/git-pull`; - hubURL.search = `?${query}`; - - window?.open(hubURL, '_blank')?.focus(); - props.onLaunch?.(); + onLaunch?.(); }, - [defaultHubBaseURL, query], + [defaultHubBaseURL, buildLink, onLaunch], ); return ( - +

Launch on a JupyterHub

@@ -394,11 +473,20 @@ function JupyterHubLaunchContent(props: JupyterHubLaunchProps) { /> - - - + +
+ + + + +
); } From bcadf4d8934c9fa34eda7cf77dec169f3e1dd2e5 Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Mon, 2 Dec 2024 16:55:47 +0000 Subject: [PATCH 09/16] feat: add launch icon --- packages/frontmatter/src/FrontmatterBlock.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/frontmatter/src/FrontmatterBlock.tsx b/packages/frontmatter/src/FrontmatterBlock.tsx index 9a192778..bc0711ae 100644 --- a/packages/frontmatter/src/FrontmatterBlock.tsx +++ b/packages/frontmatter/src/FrontmatterBlock.tsx @@ -7,7 +7,7 @@ import { LicenseBadges } from './licenses.js'; import { DownloadsDropdown } from './downloads.js'; import { AuthorAndAffiliations, AuthorsList } from './Authors.js'; import * as Popover from '@radix-ui/react-popover'; -import { RocketIcon, Cross2Icon, ClipboardCopyIcon } from '@radix-ui/react-icons'; +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'; @@ -380,8 +380,8 @@ function BinderLaunchContent(props: BinderLaunchProps) {
- - Date: Mon, 2 Dec 2024 17:09:35 +0000 Subject: [PATCH 10/16] refactor: use state rather than refs --- packages/frontmatter/src/FrontmatterBlock.tsx | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/packages/frontmatter/src/FrontmatterBlock.tsx b/packages/frontmatter/src/FrontmatterBlock.tsx index bc0711ae..f06e99e4 100644 --- a/packages/frontmatter/src/FrontmatterBlock.tsx +++ b/packages/frontmatter/src/FrontmatterBlock.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useCallback } from 'react'; +import React, { useRef, useCallback, useState } from 'react'; import classNames from 'classnames'; import type { PageFrontmatter } from 'myst-frontmatter'; import { SourceFileKind } from 'myst-spec-ext'; @@ -273,30 +273,32 @@ type CopyButtonProps = { function CopyButton(props: CopyButtonProps) { const { className, defaultMessage, alternateMessage, buildLink, timeout } = props; - - const messageRef = useRef(null); + const [message, setMessage] = useState(defaultMessage); const copyLink = useCallback(() => { + // Build the link for the clipboard const link = props.buildLink(); + // In secure links, if we have a link, we can copy it! if (window.isSecureContext && link) { + // Write to clipboard window.navigator.clipboard.writeText(link); + // Update UI + setMessage(alternateMessage ?? defaultMessage); - const messageBox = messageRef.current; - if (messageBox) { - messageBox.innerText = alternateMessage ?? defaultMessage; - setTimeout(() => { - messageBox.innerText = defaultMessage; - }, timeout ?? 1000); - } + // Set callback to restore message + setTimeout(() => { + setMessage(defaultMessage); + }, timeout ?? 1000); } - }, [messageRef, defaultMessage, alternateMessage, buildLink, timeout]); + }, [defaultMessage, alternateMessage, buildLink, timeout, setMessage]); + return ( ); } @@ -306,7 +308,6 @@ function BinderLaunchContent(props: BinderLaunchProps) { const defaultBinderBaseURL = props.binder ?? 'https://mybinder.org'; // Determine Git ref - // TODO: pull this from frontmatter const refComponent = encodeURIComponent(props.ref ?? 'HEAD'); // Build binder URL path @@ -335,11 +336,9 @@ function BinderLaunchContent(props: BinderLaunchProps) { } const data = Object.fromEntries(new FormData(form) as any); - const binderURL = ensureBasename(data.url || defaultBinderBaseURL); binderURL.pathname = `${binderURL.pathname}v2/${gitComponent}/${refComponent}`; binderURL.search = `?${query}`; - return binderURL.toString(); }, [formRef, gitComponent, refComponent, query]); @@ -412,7 +411,7 @@ function JupyterHubLaunchContent(props: JupyterHubLaunchProps) { const query = encodeURLParams({ repo: props.git, urlpath: urlPath, - branch: props.ref, // TODO master/main? + branch: props.ref, }); const formRef = useRef(null); @@ -424,7 +423,6 @@ function JupyterHubLaunchContent(props: JupyterHubLaunchProps) { } const data = Object.fromEntries(new FormData(form) as any); - const rawHubBaseURL = data.url; if (!rawHubBaseURL) { return; @@ -432,7 +430,6 @@ function JupyterHubLaunchContent(props: JupyterHubLaunchProps) { const hubURL = ensureBasename(rawHubBaseURL); hubURL.pathname = `${hubURL.pathname}hub/user-redirect/git-pull`; hubURL.search = `?${query}`; - return hubURL.toString(); }, [formRef, query]); From 1370a514b8a09aad0cc4a931cd584f54f4fa22ec Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Mon, 2 Dec 2024 17:29:49 +0000 Subject: [PATCH 11/16] refactor: move to LaunchButton.tsx --- packages/frontmatter/src/FrontmatterBlock.tsx | 363 +----------------- packages/frontmatter/src/LaunchButton.tsx | 363 ++++++++++++++++++ 2 files changed, 365 insertions(+), 361 deletions(-) create mode 100644 packages/frontmatter/src/LaunchButton.tsx diff --git a/packages/frontmatter/src/FrontmatterBlock.tsx b/packages/frontmatter/src/FrontmatterBlock.tsx index f06e99e4..fea9f3f0 100644 --- a/packages/frontmatter/src/FrontmatterBlock.tsx +++ b/packages/frontmatter/src/FrontmatterBlock.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useCallback, useState } from 'react'; +import React from 'react'; import classNames from 'classnames'; import type { PageFrontmatter } from 'myst-frontmatter'; import { SourceFileKind } from 'myst-spec-ext'; @@ -6,10 +6,7 @@ import { JupyterIcon, OpenAccessIcon, GithubIcon, TwitterIcon } from '@scienceic import { LicenseBadges } from './licenses.js'; import { DownloadsDropdown } from './downloads.js'; import { AuthorAndAffiliations, AuthorsList } from './Authors.js'; -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 { LaunchButton } from './LaunchButton.js'; function ExternalOrInternalLink({ to, @@ -191,362 +188,6 @@ export function Journal({ ); } -type CommonLaunchProps = { - git: string; - location: string; - ref?: string; - onLaunch?: () => void; -}; - -type JupyterHubLaunchProps = CommonLaunchProps & { - jupyterhub?: string; -}; - -type BinderLaunchProps = CommonLaunchProps & { - binder?: string; -}; - -const GITHUB_PATTERN = /https:\/\/github.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)/; - -type GitResource = { - // Provider - provider: 'github'; - // Per-provider info - org: string; - repo: string; -}; - -/** - * Parse a Git source URL into a Git "resource" consisting of a provider and provider info - * - * @param git - git URL - */ -function parseKnownGitProvider(git: string): GitResource | undefined { - let match; - if ((match = git.match(GITHUB_PATTERN))) { - return { - provider: 'github', - org: match[1], - repo: match[2], - }; - } - return undefined; -} - -/** - * 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): URL { - // 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 new URL(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('&'); -} - -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, if we have a link, we can copy it! - if (window.isSecureContext && link) { - // 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 ( - - ); -} - -function BinderLaunchContent(props: BinderLaunchProps) { - const { onLaunch } = props; - const defaultBinderBaseURL = props.binder ?? 'https://mybinder.org'; - - // Determine Git ref - const refComponent = encodeURIComponent(props.ref ?? 'HEAD'); - - // Build binder URL path - const query = encodeURLParams({ urlpath: `/lab/tree/${props.location}` }); - - // Parse the repo, assume it is a validated GitHub URL - let gitComponent: string; - const resource = parseKnownGitProvider(props.git); - switch (resource?.provider) { - case 'github': { - gitComponent = `gh/${resource.org}/${resource.repo}`; - break; - } - default: { - const escapedURL = encodeURIComponent(props.git); - gitComponent = `git/${escapedURL}`; - } - } - - const formRef = useRef(null); - - const buildLink = useCallback(() => { - const form = formRef.current; - if (!form) { - return; - } - - const data = Object.fromEntries(new FormData(form) as any); - const binderURL = ensureBasename(data.url || defaultBinderBaseURL); - binderURL.pathname = `${binderURL.pathname}v2/${gitComponent}/${refComponent}`; - binderURL.search = `?${query}`; - return binderURL.toString(); - }, [formRef, gitComponent, refComponent, query]); - - 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 -

- -
- Binder URL - - Please provide a valid URL - -
- - - -
-
- - - - -
-
- ); -} - -function JupyterHubLaunchContent(props: JupyterHubLaunchProps) { - const { onLaunch } = props; - const defaultHubBaseURL = props.jupyterhub ?? ''; - - const resource = parseKnownGitProvider(props.git); - - let urlPath = 'lab/tree'; - switch (resource?.provider) { - case 'github': { - urlPath = `${urlPath}/${resource.repo}${props.location}`; - } - } - - // Encode query for nbgitpuller - const query = encodeURLParams({ - repo: props.git, - urlpath: urlPath, - branch: props.ref, - }); - - 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 hubURL = ensureBasename(rawHubBaseURL); - hubURL.pathname = `${hubURL.pathname}hub/user-redirect/git-pull`; - hubURL.search = `?${query}`; - return hubURL.toString(); - }, [formRef, query]); - - 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 enter a URL - - - - Please provide a valid URL - -
- - - -
- -
- - - - -
-
- ); -} - -function LaunchButton(props: BinderLaunchProps | JupyterHubLaunchProps) { - const closeRef = useRef(null); - const closePopover = useCallback(() => { - closeRef.current?.click?.(); - }, []); - return ( - - - - - - - - - - Binder - - - JupyterHub - - - - - - - - - - - - - - - - - ); -} - export function FrontmatterBlock({ frontmatter, kind = SourceFileKind.Article, diff --git a/packages/frontmatter/src/LaunchButton.tsx b/packages/frontmatter/src/LaunchButton.tsx new file mode 100644 index 00000000..cd84bb84 --- /dev/null +++ b/packages/frontmatter/src/LaunchButton.tsx @@ -0,0 +1,363 @@ +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'; + +type CommonLaunchProps = { + git: string; + location: string; + ref?: string; + onLaunch?: () => void; +}; + +type JupyterHubLaunchProps = CommonLaunchProps & { + jupyterhub?: string; +}; + +type BinderLaunchProps = CommonLaunchProps & { + binder?: string; +}; + +const GITHUB_PATTERN = /https:\/\/github.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)/; + +type GitResource = { + // Provider + provider: 'github'; + // Per-provider info + org: string; + repo: string; +}; + +/** + * Parse a Git source URL into a Git "resource" consisting of a provider and provider info + * + * @param git - git URL + */ +function parseKnownGitProvider(git: string): GitResource | undefined { + let match; + if ((match = git.match(GITHUB_PATTERN))) { + return { + provider: 'github', + org: match[1], + repo: match[2], + }; + } + return undefined; +} + +/** + * 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): URL { + // 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 new URL(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('&'); +} + +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, if we have a link, we can copy it! + if (window.isSecureContext && link) { + // 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 ( + + ); +} + +function BinderLaunchContent(props: BinderLaunchProps) { + const { onLaunch } = props; + const defaultBinderBaseURL = props.binder ?? 'https://mybinder.org'; + + // Determine Git ref + const refComponent = encodeURIComponent(props.ref ?? 'HEAD'); + + // Build binder URL path + const query = encodeURLParams({ urlpath: `/lab/tree/${props.location}` }); + + // Parse the repo, assume it is a validated GitHub URL + let gitComponent: string; + const resource = parseKnownGitProvider(props.git); + switch (resource?.provider) { + case 'github': { + gitComponent = `gh/${resource.org}/${resource.repo}`; + break; + } + default: { + const escapedURL = encodeURIComponent(props.git); + gitComponent = `git/${escapedURL}`; + } + } + + const formRef = useRef(null); + + const buildLink = useCallback(() => { + const form = formRef.current; + if (!form) { + return; + } + + const data = Object.fromEntries(new FormData(form) as any); + const binderURL = ensureBasename(data.url || defaultBinderBaseURL); + binderURL.pathname = `${binderURL.pathname}v2/${gitComponent}/${refComponent}`; + binderURL.search = `?${query}`; + return binderURL.toString(); + }, [formRef, gitComponent, refComponent, query]); + + 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 +

+ +
+ Binder URL + + Please provide a valid URL + +
+ + + +
+
+ + + + +
+
+ ); +} + +function JupyterHubLaunchContent(props: JupyterHubLaunchProps) { + const { onLaunch } = props; + const defaultHubBaseURL = props.jupyterhub ?? ''; + + const resource = parseKnownGitProvider(props.git); + + let urlPath = 'lab/tree'; + switch (resource?.provider) { + case 'github': { + urlPath = `${urlPath}/${resource.repo}${props.location}`; + } + } + + // Encode query for nbgitpuller + const query = encodeURLParams({ + repo: props.git, + urlpath: urlPath, + branch: props.ref, + }); + + 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 hubURL = ensureBasename(rawHubBaseURL); + hubURL.pathname = `${hubURL.pathname}hub/user-redirect/git-pull`; + hubURL.search = `?${query}`; + return hubURL.toString(); + }, [formRef, query]); + + 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 enter a URL + + + + Please provide a valid URL + +
+ + + +
+ +
+ + + + +
+
+ ); +} + +export function LaunchButton(props: BinderLaunchProps | JupyterHubLaunchProps) { + const closeRef = useRef(null); + const closePopover = useCallback(() => { + closeRef.current?.click?.(); + }, []); + return ( + + + + + + + + + + Binder + + + JupyterHub + + + + + + + + + + + + + + + + + ); +} From 724ce76d5af98b2012b9f89b5794c31ae0db1d93 Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Mon, 2 Dec 2024 17:47:07 +0000 Subject: [PATCH 12/16] fix: don't store invalid link in clip --- packages/frontmatter/src/LaunchButton.tsx | 35 +++++++++++++++++------ 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/frontmatter/src/LaunchButton.tsx b/packages/frontmatter/src/LaunchButton.tsx index cd84bb84..d59c7225 100644 --- a/packages/frontmatter/src/LaunchButton.tsx +++ b/packages/frontmatter/src/LaunchButton.tsx @@ -93,10 +93,10 @@ function CopyButton(props: CopyButtonProps) { const copyLink = useCallback(() => { // Build the link for the clipboard const link = props.buildLink(); - // In secure links, if we have a link, we can copy it! - if (window.isSecureContext && link) { + // In secure links, we can copy it! + if (window.isSecureContext) { // Write to clipboard - window.navigator.clipboard.writeText(link); + window.navigator.clipboard.writeText(link ?? ''); // Update UI setMessage(alternateMessage ?? defaultMessage); @@ -143,7 +143,6 @@ function BinderLaunchContent(props: BinderLaunchProps) { } const formRef = useRef(null); - const buildLink = useCallback(() => { const form = formRef.current; if (!form) { @@ -157,6 +156,16 @@ function BinderLaunchContent(props: BinderLaunchProps) { return binderURL.toString(); }, [formRef, gitComponent, refComponent, query]); + // 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(); @@ -171,7 +180,6 @@ function BinderLaunchContent(props: BinderLaunchProps) { }, [defaultBinderBaseURL, buildLink, onLaunch], ); - return (

@@ -189,6 +197,7 @@ function BinderLaunchContent(props: BinderLaunchProps) { className="box-border inline-flex h-[35px] w-full appearance-none items-center justify-center rounded px-2.5 text-[15px] leading-none shadow-[0_0_0_1px] shadow-slate-400 outline-none bg-gray-50 dark:bg-gray-700 hover:shadow-[0_0_0_1px_black] focus:shadow-[0_0_0_2px_black]" type="url" placeholder={defaultBinderBaseURL} + ref={urlRef} /> @@ -202,7 +211,7 @@ function BinderLaunchContent(props: BinderLaunchProps) { className="inline-flex h-[35px] items-center justify-center rounded px-[15px] font-medium leading-none bg-gray-400 hover:bg-gray-500 outline-none text-white focus:shadow-[0_0_0_2px] focus:shadow-black focus:outline-none" defaultMessage="Copy Link" alternateMessage="Copied Link" - buildLink={buildLink} + buildLink={buildValidLink} />

@@ -230,7 +239,6 @@ function JupyterHubLaunchContent(props: JupyterHubLaunchProps) { }); const formRef = useRef(null); - const buildLink = useCallback(() => { const form = formRef.current; if (!form) { @@ -248,6 +256,16 @@ function JupyterHubLaunchContent(props: JupyterHubLaunchProps) { return hubURL.toString(); }, [formRef, query]); + // 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(); @@ -282,6 +300,7 @@ function JupyterHubLaunchContent(props: JupyterHubLaunchProps) { className="box-border inline-flex h-[35px] w-full appearance-none items-center justify-center rounded px-2.5 text-[15px] leading-none shadow-[0_0_0_1px] shadow-slate-400 outline-none bg-gray-50 dark:bg-gray-700 hover:shadow-[0_0_0_1px_black] focus:shadow-[0_0_0_2px_black]" type="url" required + ref={urlRef} /> @@ -296,7 +315,7 @@ function JupyterHubLaunchContent(props: JupyterHubLaunchProps) { className="inline-flex h-[35px] items-center justify-center rounded px-[15px] font-medium leading-none bg-gray-400 hover:bg-gray-500 outline-none text-white focus:shadow-[0_0_0_2px] focus:shadow-black focus:outline-none" defaultMessage="Copy Link" alternateMessage="Copied Link" - buildLink={buildLink} + buildLink={buildValidLink} />
From eb592b280f96444b13b0c6c56c7121408fe7f1a5 Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Tue, 3 Dec 2024 14:58:45 +0000 Subject: [PATCH 13/16] fix: use project frontmatter --- packages/frontmatter/src/FrontmatterBlock.tsx | 19 ++++++++++--------- packages/frontmatter/src/LaunchButton.tsx | 10 +++++----- packages/site/src/pages/Article.tsx | 14 +++++++++++++- themes/article/app/components/Article.tsx | 14 +++++++++++++- .../components/ArticlePageAndNavigation.tsx | 4 ++-- themes/book/.eslintrc.js | 2 +- themes/book/app/components/ArticlePage.tsx | 15 ++++++++++++++- 7 files changed, 58 insertions(+), 20 deletions(-) diff --git a/packages/frontmatter/src/FrontmatterBlock.tsx b/packages/frontmatter/src/FrontmatterBlock.tsx index fea9f3f0..5f2244b5 100644 --- a/packages/frontmatter/src/FrontmatterBlock.tsx +++ b/packages/frontmatter/src/FrontmatterBlock.tsx @@ -188,6 +188,13 @@ export function Journal({ ); } +export type LaunchOptions = { + repo: string; + location: string; + binder?: string; + jupyterhub?: string; +}; + export function FrontmatterBlock({ frontmatter, kind = SourceFileKind.Article, @@ -195,7 +202,7 @@ export function FrontmatterBlock({ hideBadges, hideExports, className, - location, + launchOptions, }: { frontmatter: Omit; kind?: SourceFileKind; @@ -203,7 +210,7 @@ export function FrontmatterBlock({ hideBadges?: boolean; hideExports?: boolean; className?: string; - location?: string; + launchOptions?: LaunchOptions; }) { if (!frontmatter) return null; const { @@ -272,13 +279,7 @@ export function FrontmatterBlock({ )} {!hideExports && } - {!hideLaunch && frontmatter.github && location && ( - - )} + {!hideLaunch && launchOptions && }
)} {title &&

{title}

} diff --git a/packages/frontmatter/src/LaunchButton.tsx b/packages/frontmatter/src/LaunchButton.tsx index d59c7225..f8e4088f 100644 --- a/packages/frontmatter/src/LaunchButton.tsx +++ b/packages/frontmatter/src/LaunchButton.tsx @@ -7,7 +7,7 @@ import * as Tabs from '@radix-ui/react-tabs'; import * as Form from '@radix-ui/react-form'; type CommonLaunchProps = { - git: string; + repo: string; location: string; ref?: string; onLaunch?: () => void; @@ -130,14 +130,14 @@ function BinderLaunchContent(props: BinderLaunchProps) { // Parse the repo, assume it is a validated GitHub URL let gitComponent: string; - const resource = parseKnownGitProvider(props.git); + const resource = parseKnownGitProvider(props.repo); switch (resource?.provider) { case 'github': { gitComponent = `gh/${resource.org}/${resource.repo}`; break; } default: { - const escapedURL = encodeURIComponent(props.git); + const escapedURL = encodeURIComponent(props.repo); gitComponent = `git/${escapedURL}`; } } @@ -222,7 +222,7 @@ function JupyterHubLaunchContent(props: JupyterHubLaunchProps) { const { onLaunch } = props; const defaultHubBaseURL = props.jupyterhub ?? ''; - const resource = parseKnownGitProvider(props.git); + const resource = parseKnownGitProvider(props.repo); let urlPath = 'lab/tree'; switch (resource?.provider) { @@ -233,7 +233,7 @@ function JupyterHubLaunchContent(props: JupyterHubLaunchProps) { // Encode query for nbgitpuller const query = encodeURLParams({ - repo: props.git, + repo: props.repo, urlpath: urlPath, branch: props.ref, }); diff --git a/packages/site/src/pages/Article.tsx b/packages/site/src/pages/Article.tsx index ad47b152..aaa45720 100644 --- a/packages/site/src/pages/Article.tsx +++ b/packages/site/src/pages/Article.tsx @@ -43,6 +43,18 @@ export const ArticlePage = React.memo(function ({ const tree = copyNode(article.mdast); const keywords = article.frontmatter?.keywords ?? []; const parts = extractKnownParts(tree, article.frontmatter?.parts); + const launchOptions = React.useMemo(() => { + if (!manifest) { + return undefined; + } + const repo = manifest.thebe?.binder?.repo ?? manifest.github; + if (!repo) { + return undefined; + } + const binder = manifest.thebe?.binder?.url; + const location = article.location; + return { repo, binder, location }; + }, [manifest, article.location]); return ( )} diff --git a/themes/article/app/components/Article.tsx b/themes/article/app/components/Article.tsx index b4408888..58d08b8c 100644 --- a/themes/article/app/components/Article.tsx +++ b/themes/article/app/components/Article.tsx @@ -39,6 +39,18 @@ export function Article({ const compute = useComputeOptions(); const top = useThemeTop(); const isOutlineMargin = useMediaQuery('(min-width: 1024px)'); + const launchOptions = React.useMemo(() => { + if (!manifest) { + return undefined; + } + const repo = manifest.thebe?.binder?.repo ?? manifest.github; + if (!repo) { + return undefined; + } + const binder = manifest.thebe?.binder?.url; + const location = article.location; + return { repo, binder, location }; + }, [manifest, article.location]); return ( )} diff --git a/themes/article/app/components/ArticlePageAndNavigation.tsx b/themes/article/app/components/ArticlePageAndNavigation.tsx index 50564027..bf0e944b 100644 --- a/themes/article/app/components/ArticlePageAndNavigation.tsx +++ b/themes/article/app/components/ArticlePageAndNavigation.tsx @@ -6,9 +6,9 @@ export function ArticlePageAndNavigation({ children }: { children: React.ReactNo -
+
-
+
{children}
diff --git a/themes/book/.eslintrc.js b/themes/book/.eslintrc.js index 2061cd22..f2faf147 100644 --- a/themes/book/.eslintrc.js +++ b/themes/book/.eslintrc.js @@ -1,4 +1,4 @@ /** @type {import('eslint').Linter.Config} */ module.exports = { - extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], + extends: ['@remix-run/eslint-config', '@remix-run/eslint-config/node'], }; diff --git a/themes/book/app/components/ArticlePage.tsx b/themes/book/app/components/ArticlePage.tsx index d5fdf76b..a475cebf 100644 --- a/themes/book/app/components/ArticlePage.tsx +++ b/themes/book/app/components/ArticlePage.tsx @@ -76,6 +76,19 @@ export const ArticlePage = React.memo(function ({ const keywords = article.frontmatter?.keywords ?? []; const parts = extractKnownParts(tree, article.frontmatter?.parts); const isOutlineMargin = useMediaQuery('(min-width: 1024px)'); + const launchOptions = React.useMemo(() => { + if (!manifest) { + return undefined; + } + const repo = manifest.thebe?.binder?.repo ?? manifest.github; + if (!repo) { + return undefined; + } + const binder = manifest.thebe?.binder?.url; + const location = article.location; + return { repo, binder, location }; + }, [manifest, article.location]); + return ( )} {!hide_outline && ( From 24e4dca54cb6ec298f125b81997011808621120f Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Tue, 3 Dec 2024 15:01:16 +0000 Subject: [PATCH 14/16] chore: revert style changes --- themes/article/app/components/ArticlePageAndNavigation.tsx | 4 ++-- themes/book/.eslintrc.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/themes/article/app/components/ArticlePageAndNavigation.tsx b/themes/article/app/components/ArticlePageAndNavigation.tsx index bf0e944b..50564027 100644 --- a/themes/article/app/components/ArticlePageAndNavigation.tsx +++ b/themes/article/app/components/ArticlePageAndNavigation.tsx @@ -6,9 +6,9 @@ export function ArticlePageAndNavigation({ children }: { children: React.ReactNo -
+
-
+
{children}
diff --git a/themes/book/.eslintrc.js b/themes/book/.eslintrc.js index f2faf147..2061cd22 100644 --- a/themes/book/.eslintrc.js +++ b/themes/book/.eslintrc.js @@ -1,4 +1,4 @@ /** @type {import('eslint').Linter.Config} */ module.exports = { - extends: ['@remix-run/eslint-config', '@remix-run/eslint-config/node'], + extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], }; From 9be73bfae0da0a29714f8f3cb1c011918f38bc3a Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Tue, 3 Dec 2024 15:16:10 +0000 Subject: [PATCH 15/16] fix: just export types for now --- packages/frontmatter/src/FrontmatterBlock.tsx | 9 ++------- packages/frontmatter/src/LaunchButton.tsx | 6 +++--- packages/site/src/pages/Article.tsx | 3 ++- themes/article/app/components/Article.tsx | 12 ++++++++++-- themes/book/app/components/ArticlePage.tsx | 3 ++- 5 files changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/frontmatter/src/FrontmatterBlock.tsx b/packages/frontmatter/src/FrontmatterBlock.tsx index 5f2244b5..e700e180 100644 --- a/packages/frontmatter/src/FrontmatterBlock.tsx +++ b/packages/frontmatter/src/FrontmatterBlock.tsx @@ -6,7 +6,7 @@ import { JupyterIcon, OpenAccessIcon, GithubIcon, TwitterIcon } from '@scienceic import { LicenseBadges } from './licenses.js'; import { DownloadsDropdown } from './downloads.js'; import { AuthorAndAffiliations, AuthorsList } from './Authors.js'; -import { LaunchButton } from './LaunchButton.js'; +import { LaunchButton, BinderLaunchProps, JupyterHubLaunchProps } from './LaunchButton.js'; function ExternalOrInternalLink({ to, @@ -188,12 +188,7 @@ export function Journal({ ); } -export type LaunchOptions = { - repo: string; - location: string; - binder?: string; - jupyterhub?: string; -}; +export type LaunchOptions = BinderLaunchProps | JupyterHubLaunchProps; export function FrontmatterBlock({ frontmatter, diff --git a/packages/frontmatter/src/LaunchButton.tsx b/packages/frontmatter/src/LaunchButton.tsx index f8e4088f..f7511a18 100644 --- a/packages/frontmatter/src/LaunchButton.tsx +++ b/packages/frontmatter/src/LaunchButton.tsx @@ -6,18 +6,18 @@ import { RocketIcon, Cross2Icon, ClipboardCopyIcon, ExternalLinkIcon } from '@ra import * as Tabs from '@radix-ui/react-tabs'; import * as Form from '@radix-ui/react-form'; -type CommonLaunchProps = { +export type CommonLaunchProps = { repo: string; location: string; ref?: string; onLaunch?: () => void; }; -type JupyterHubLaunchProps = CommonLaunchProps & { +export type JupyterHubLaunchProps = CommonLaunchProps & { jupyterhub?: string; }; -type BinderLaunchProps = CommonLaunchProps & { +export type BinderLaunchProps = CommonLaunchProps & { binder?: string; }; diff --git a/packages/site/src/pages/Article.tsx b/packages/site/src/pages/Article.tsx index aaa45720..29781db3 100644 --- a/packages/site/src/pages/Article.tsx +++ b/packages/site/src/pages/Article.tsx @@ -53,7 +53,8 @@ export const ArticlePage = React.memo(function ({ } const binder = manifest.thebe?.binder?.url; const location = article.location; - return { repo, binder, location }; + const ref = manifest.thebe?.binder?.ref; + return { repo, binder, location, ref }; }, [manifest, article.location]); return ( diff --git a/themes/article/app/components/Article.tsx b/themes/article/app/components/Article.tsx index 58d08b8c..e649693c 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); @@ -49,7 +56,8 @@ export function Article({ } const binder = manifest.thebe?.binder?.url; const location = article.location; - return { repo, binder, location }; + const ref = manifest.thebe?.binder?.ref; + return { repo, binder, location, ref }; }, [manifest, article.location]); return ( Date: Tue, 3 Dec 2024 15:30:42 +0000 Subject: [PATCH 16/16] fix: import type --- packages/frontmatter/src/FrontmatterBlock.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/frontmatter/src/FrontmatterBlock.tsx b/packages/frontmatter/src/FrontmatterBlock.tsx index e700e180..02efd1cd 100644 --- a/packages/frontmatter/src/FrontmatterBlock.tsx +++ b/packages/frontmatter/src/FrontmatterBlock.tsx @@ -6,7 +6,8 @@ import { JupyterIcon, OpenAccessIcon, GithubIcon, TwitterIcon } from '@scienceic import { LicenseBadges } from './licenses.js'; import { DownloadsDropdown } from './downloads.js'; import { AuthorAndAffiliations, AuthorsList } from './Authors.js'; -import { LaunchButton, BinderLaunchProps, JupyterHubLaunchProps } from './LaunchButton.js'; +import { LaunchButton } from './LaunchButton.js'; +import type { BinderLaunchProps, JupyterHubLaunchProps } from './LaunchButton.js'; function ExternalOrInternalLink({ to,