From 1370a514b8a09aad0cc4a931cd584f54f4fa22ec Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Mon, 2 Dec 2024 17:29:49 +0000 Subject: [PATCH] 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 + + + + + + + + + + + + + + + + + ); +}