diff --git a/README.md b/README.md index b381f2a..2085071 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,10 @@ npm run dev ``` / site root -/markdown for inline markdown input -/gist/username/... for gist -/github/owner/repo/... TODO: for github - +/gist/owner/gist for gist URLs +/markdown for inline markdown or unknown external URL +/local for local file +/about about +/github/owner/repo/... TODO: for github repo URLs +/_player TODO: a remote-controllable player, with content and progress sync ``` diff --git a/core/package.json b/core/package.json new file mode 100644 index 0000000..b551e82 --- /dev/null +++ b/core/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ihate-work/slides-core", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC" +} diff --git a/package-lock.json b/package-lock.json index 266f9ec..90ebdec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,11 +12,14 @@ "./web" ], "devDependencies": { + "@types/jest": "^29", "@types/node": "^20", "eslint-plugin-import": "^2.29.0", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "gts": "^5.2.0", + "jest": "^29", + "ts-jest": "^29", "typescript": "^5.4", "wrangler": "^3.36.0" }, @@ -2235,6 +2238,46 @@ } } }, + "node_modules/@mui/lab": { + "version": "5.0.0-alpha.169", + "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-5.0.0-alpha.169.tgz", + "integrity": "sha512-h6xe1K6ISKUbyxTDgdvql4qoDP6+q8ad5fg9nXQxGLUrIeT2jVrBuT/jRECSTufbnhzP+V5kulvYxaMfM8rEdA==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/base": "5.0.0-beta.40", + "@mui/system": "^5.15.14", + "@mui/types": "^7.2.14", + "@mui/utils": "^5.15.14", + "clsx": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material": ">=5.15.0", + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/material": { "version": "5.15.14", "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.14.tgz", @@ -14931,6 +14974,7 @@ "@emotion/styled": "^11.11.0", "@jokester/ts-commonutil": "^0.5.0", "@mui/icons-material": "^5.15.0", + "@mui/lab": "^5.0.0-alpha.169", "@mui/material": "^5.15.0", "@next/eslint-plugin-next": "^14.0.4", "@octokit/core": "^5.1.0", @@ -14957,7 +15001,7 @@ "devDependencies": { "@next/bundle-analyzer": "^14", "@types/debug": "^4.1.12", - "@types/jest": "^29", + "@types/jest": "*", "@types/lodash-es": "^4.17.12", "@types/node": "*", "@types/react": "^18.2.45", @@ -14965,11 +15009,11 @@ "@types/reveal.js": "^5.0.1", "autoprefixer": "^10", "gts": "*", - "jest": "^29", + "jest": "*", "postcss": "^8.4.32", "sass": "^1.69.5", "tailwindcss": "^3.3.6", - "ts-jest": "^29", + "ts-jest": "*", "typescript": "*", "vercel": "^33.6", "wrangler": "*" diff --git a/package.json b/package.json index 742924e..bac7a76 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "description": "https://slides.ihate.work", "scripts": { "wrangler": "wrangler", + "dev": "npm run --workspace=web dev", "test": "echo \"Error: no test specified\" && exit 1" }, "engines": { @@ -14,11 +15,14 @@ }, "dependencies": {}, "devDependencies": { + "@types/jest": "^29", "@types/node": "^20", "eslint-plugin-import": "^2.29.0", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "gts": "^5.2.0", + "jest": "^29", + "ts-jest": "^29", "typescript": "^5.4", "wrangler": "^3.36.0" }, diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index a18214b..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "isolatedModules": true, - "jsx": "preserve", - "module": "esnext", - "moduleResolution": "node", - "noEmit": true, - "target": "esnext", - "skipLibCheck": true, - "typeRoots": [ - "typings", - "node_modules/@types" - ], - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], - "incremental": true - }, - "exclude": [ - "node_modules", - "scripts", - "coverage", - "build", - "__test__", - "bin", - "jest.config.js" - ], - "include": [ - "pages", - "src" - ] -} diff --git a/web/jest.config.js b/web/jest.config.js index a868f92..a6d10a7 100644 --- a/web/jest.config.js +++ b/web/jest.config.js @@ -1,7 +1,7 @@ module.exports = { preset: 'ts-jest/presets/js-with-ts', roots: ['src', 'test'], - transformIgnorePatterns: ['/node_modules/.*\\.js', '/build/.*\\.js'], + transformIgnorePatterns: ['node_modules/.*\\.js', '/build/.*\\.js'], testMatch: ['**/*\\.(spec|test)\\.(ts|js|tsx|jsx)'], collectCoverageFrom: ['src/**/*.(ts|tsx)', '!out/', '!build/', '!**/node_modules', '!/coverage'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], @@ -11,11 +11,20 @@ module.exports = { '/test/mocks/resolves-to-path.json', '\\.(css|less|scss|sass)$': '/test/mocks/resolves-to-path.json', }, - globals: { - 'ts-jest': { - tsconfig: { - jsx: 'react', + transform: { + '^.+\\.(ts|tsx)$': [ + 'ts-jest', + { + 'ts-jest': { + tsconfig: { + isolatedModules: true, + jsx: 'react', + diagnostics: { + exclude: ['**'], + }, + }, + }, }, - }, + ], }, }; diff --git a/web/package.json b/web/package.json index 98ff2e4..75d7eb1 100644 --- a/web/package.json +++ b/web/package.json @@ -24,6 +24,7 @@ "@emotion/styled": "^11.11.0", "@jokester/ts-commonutil": "^0.5.0", "@mui/icons-material": "^5.15.0", + "@mui/lab": "^5.0.0-alpha.169", "@mui/material": "^5.15.0", "@next/eslint-plugin-next": "^14.0.4", "@octokit/core": "^5.1.0", @@ -50,7 +51,7 @@ "devDependencies": { "@next/bundle-analyzer": "^14", "@types/debug": "^4.1.12", - "@types/jest": "^29", + "@types/jest": "*", "@types/lodash-es": "^4.17.12", "@types/node": "*", "@types/react": "^18.2.45", @@ -58,11 +59,11 @@ "@types/reveal.js": "^5.0.1", "autoprefixer": "^10", "gts": "*", - "jest": "^29", + "jest": "*", "postcss": "^8.4.32", "sass": "^1.69.5", "tailwindcss": "^3.3.6", - "ts-jest": "^29", + "ts-jest": "*", "typescript": "*", "vercel": "^33.6", "wrangler": "*" diff --git a/web/pages/404.tsx b/web/pages/404.tsx index 4e7f7f5..6f08999 100644 --- a/web/pages/404.tsx +++ b/web/pages/404.tsx @@ -1,5 +1,6 @@ import { useRouter } from 'next/router'; import { useEffect } from 'react'; +import { PageContainer, PageHeader } from '../src/layouts'; export default function NotFoundPage() { const router = useRouter(); @@ -9,5 +10,10 @@ export default function NotFoundPage() { }, 5e3); return () => clearTimeout(timer); }, [router]); - return
Page not found. You will be redirected to site root shortly.
; + return ( + + +
Page not found. You will be redirected to site root shortly.
+
+ ); } diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index f3f7831..0dc4000 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -5,6 +5,8 @@ import { DefaultMeta } from '../src/components/meta/default-meta'; import { SnackbarProvider } from 'notistack'; import Head from 'next/head'; import { useRevealPreload } from '../src/player/use-reveal-preload'; +import { ThemeProvider } from '@mui/material'; +import { globalTheme } from '../src/layouts/theme'; const CustomApp: React.FC & Partial> = (props) => { useRevealPreload(); @@ -15,12 +17,14 @@ const CustomApp: React.FC & Partial - - + + + + ); }; diff --git a/web/pages/_document.tsx b/web/pages/_document.tsx index d902c51..2cf2db5 100644 --- a/web/pages/_document.tsx +++ b/web/pages/_document.tsx @@ -16,18 +16,16 @@ const stylesheetPreloads = [ ), ]; -const defaultStyleSheets = [ - // , -] as const; - export default function CustomDocument(): React.ReactElement { return ( + + {stylesheetPreloads} diff --git a/web/pages/about.tsx b/web/pages/about.tsx index c701f4c..ade4170 100644 --- a/web/pages/about.tsx +++ b/web/pages/about.tsx @@ -1,36 +1,70 @@ -import { ExampleLinks } from '../src/dummy/example-links'; import * as React from 'react'; import { NextPage } from 'next'; -import { useRouter } from 'next/router'; -import { OnlyInBrowser } from '../src/components/OnlyInBrowser'; -/** - * - */ -interface PageProps { - renderedAt: number; - renderedBy: string; +import { MarkdownHelp, PageContainer, CreditsFooter, PageHeader } from '../src/layouts'; +import { globalStyles } from '../src/layouts/theme'; + +function RevealjsHelp() { + return ( +
+

About Markdown and Slide Syntax

+ +
+ ); } -const AboutPage: NextPage = (props) => { - const query = useRouter().query; +function UsefulTools() { return ( - <> -

AboutPage in {__filename}

- {/**/} - {/**/} - - - - +
+

Other useful Markdown + Slide tools

+ +
); -}; +} -export const runtime = 'experimental-edge'; +const AboutPage: NextPage = (props) => { + return ( + + + +
+ +
+ +
+ +
+ ); +}; -// @ts-ignore -AboutPage.DISABLED_getInitialProps = async (ctx) => ({ - renderedAt: 0 && Date.now(), - renderedBy: ctx.req ? 'server' : 'browser', -}); +// export const runtime = 'experimental-edge'; export default AboutPage; diff --git a/web/pages/gist/[...pathSegments].tsx b/web/pages/gist/[...pathSegments].tsx index 655887b..cd476c3 100644 --- a/web/pages/gist/[...pathSegments].tsx +++ b/web/pages/gist/[...pathSegments].tsx @@ -1,18 +1,19 @@ import debug from 'debug'; import { useRouter } from 'next/router'; -import { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { GistSource } from '../../src/core/GistSource'; import useSWR from 'swr'; import { GistTextarea } from '../../src/gist/gist-textarea'; import { PageContainer, PageHeader } from '../../src/layouts'; import { RevealSlidePlayer } from '../../src/player/reveal-slide-player'; import { useRenderSwr } from '../../src/components/useRenderSwr'; +import clsx from 'clsx'; +import { StartPlaybackButton } from '../../src/markdown/markdown-textarea'; const logger = debug('pages:gist'); function GistSourcePageContent({ src }: { src: GistSource }) { const fetched = useSWR(src.fetchKey, async () => src.fetchSource()); - // local modified text const [text, setText] = useState(''); const [playback, setPlayback] = useState(false); const onStartPlayback = (newText: string) => { @@ -21,7 +22,20 @@ function GistSourcePageContent({ src }: { src: GistSource }) { }; const textArea = useRenderSwr(fetched, (v) => ( - + <> +
+ onStartPlayback(v.slideText)} /> +
+ + {v.gistSource && ( + + )} + )); logger(src, fetched); @@ -54,7 +68,7 @@ export default function GistPresentPage() { try { const url = new URL(location.href); url.pathname = url.pathname.slice('/gist'.length); - const src = new GistSource(url.toString()); + const src = new GistSource(url); setSrc(src); } catch (e: any) { const err = new Error(e?.message || 'error', { @@ -65,11 +79,21 @@ export default function GistPresentPage() { }, [router, router.isReady]); if (!src) { - return
Loading...
; + return ( + + +
Loading...
+
+ ); } if (src instanceof Error) { - return
Error: {src.message}
; + return ( + + +
Error: {src.message}
+
+ ); } return ; diff --git a/web/pages/index.tsx b/web/pages/index.tsx index 25cbca9..ea9a97b 100644 --- a/web/pages/index.tsx +++ b/web/pages/index.tsx @@ -1,17 +1,19 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { NextPage } from 'next'; import { useRouter } from 'next/router'; -import { MarkdownHelp, PageContainer, PageFooter, PageHeader } from '../src/layouts'; +import { MarkdownHelp, PageContainer, CreditsFooter, PageHeader } from '../src/layouts'; import { ExternalSourceInput } from '../src/components/external-src-input'; import debug from 'debug'; import { rewriteUrlToRoute } from '../src/routes/url-rewrite'; import Link from 'next/link'; -import { Button } from '@mui/material'; +import { Box, Button, Divider, Tab, Tabs } from '@mui/material'; +import { TabContext, TabList, TabPanel } from '@mui/lab'; const logger = debug('pages:index'); function IndexPageContent() { const router = useRouter(); + const [activeTab, setActiveTab] = useState('0'); const onSourceUrlSubmit = (url: string) => { const nextPath = rewriteUrlToRoute(url); logger('onSourceUrlSubmit', url, nextPath); @@ -23,22 +25,46 @@ function IndexPageContent() { router.push(`/markdown?markdownUrl=${encodeURIComponent(url)}`); } }; + + const openTabContent = ( + <> +
+ +
+ + ); + return ( -
- -
-

or

-
- - - + +
+ + + setActiveTab(newValue)}> + + + + + + {openTabContent} + + + + + + + + + + +
-
- - + + + + ); } diff --git a/web/pages/local.tsx b/web/pages/local.tsx new file mode 100644 index 0000000..acc245f --- /dev/null +++ b/web/pages/local.tsx @@ -0,0 +1,49 @@ +import React, { useState } from 'react'; +import { SlideBundle } from '../src/core/SlideBundle'; +import { defaultSlideText } from '../src/markdown/markdown-form'; +import { DefaultMeta } from '../src/components/meta/default-meta'; +import { PageContainer, PageHeader } from '../src/layouts'; +import { ClearButton, MarkdownTextarea, OpenFileButton, StartPlaybackButton } from '../src/markdown/markdown-textarea'; +import { RevealSlidePlayer } from '../src/player/reveal-slide-player'; +import clsx from 'clsx'; + +export default function LocalMarkdownPage() { + const [text, setText] = useState(''); + const [playback, setPlayback] = useState(null); + const onTextChange = (newText: string, isManualEdit: boolean) => { + if (isManualEdit || !text || newText === text || text === defaultSlideText) { + setText(newText); + } else if (confirm('Overwrite current input?')) { + setText(newText); + } + }; + const onStartPlayback = () => { + setPlayback({ + slideText: text, + }); + }; + + if (!playback) { + return ( + + + +
+ {text ? ( + <> + + setText('')} /> + + ) : ( + <> + setText(v.slideText)} /> +   or type slide text + + )} +
+ +
+ ); + } + return setPlayback(null)} bundle={playback} />; +} diff --git a/web/pages/markdown.tsx b/web/pages/markdown.tsx index b145a4a..a8140fb 100644 --- a/web/pages/markdown.tsx +++ b/web/pages/markdown.tsx @@ -1,7 +1,7 @@ -import { useState } from 'react'; +import React, { useState } from 'react'; import { DefaultMeta } from '../src/components/meta/default-meta'; -import { MarkdownForm, defaultSlideText } from '../src/markdown/markdown-form'; -import { MarkdownHelp, PageContainer, PageFooter, PageHeader } from '../src/layouts'; +import { defaultSlideText } from '../src/markdown/markdown-form'; +import { PageContainer, PageHeader } from '../src/layouts'; import { useAsyncEffect } from '@jokester/ts-commonutil/lib/react/hook/use-async-effect'; import { isUrl } from '../src/core/url-loader'; import { rewriteUrlToRoute } from '../src/routes/url-rewrite'; @@ -10,18 +10,23 @@ import debug from 'debug'; import { useRouter } from 'next/router'; import { SlideBundle } from '../src/core/SlideBundle'; import { RevealSlidePlayer } from '../src/player/reveal-slide-player'; +import { Button } from '@mui/material'; +import { ClearButton, MarkdownTextarea, StartPlaybackButton } from '../src/markdown/markdown-textarea'; +import clsx from 'clsx'; const logger = debug('pages:markdown'); /** - * /markdown page - * for markdown input from textarea / file / general external URL + * @page /markdown + * 1. try to load Markdown text from external url + * 2. init MD form with loaded text + * 3. if no external URL specified, have user try random text or example slides */ -export default function MarkdownPage() { +export default function RemoteMarkdownPage() { const [text, setText] = useState(''); const [playback, setPlayback] = useState(null); const onTextChange = (newText: string, isManualEdit: boolean) => { - if (isManualEdit || !text || text === defaultSlideText) { + if (isManualEdit || !text || newText === text || text === defaultSlideText) { setText(newText); } else if (confirm('Overwrite current input?')) { setText(newText); @@ -41,12 +46,25 @@ export default function MarkdownPage() { if (!playback) { return ( <> - + - - -
+
+ {text ? ( + <> + + setText('')} /> + + ) : ( + <> + Type some markdown text to start , or   + + + )} +
+ ); @@ -54,7 +72,6 @@ export default function MarkdownPage() { return ( <> - setPlayback(null)} bundle={playback} /> ); @@ -73,12 +90,10 @@ export function useTextInitialize(onRawFetched: (x: string) => void) { } const markdownUrl = router.query.markdownUrl as string | undefined; if (!markdownUrl) { - onRawFetched(defaultSlideText); return; } if (!isUrl(markdownUrl)) { alert('Invalid URL'); - onRawFetched(defaultSlideText); return; } const redirect = rewriteUrlToRoute(markdownUrl); diff --git a/web/src/components/external-src-input.tsx b/web/src/components/external-src-input.tsx index 6805ef6..707960a 100644 --- a/web/src/components/external-src-input.tsx +++ b/web/src/components/external-src-input.tsx @@ -1,17 +1,20 @@ -import { Button, Input, TextField } from '@mui/material'; +import { Button, ButtonGroup, IconButton, Input, TextField } from '@mui/material'; import clsx from 'clsx'; import { useState } from 'react'; -import { useToggle } from 'react-use'; -import { detectSourceUrlType, isUrl, loadExternalSourceUrl } from '../core/url-loader'; -import { GitHub } from '@mui/icons-material'; +import { detectSourceUrlType, isUrl } from '../core/url-loader'; +import { GitHub, Clear, Http } from '@mui/icons-material'; + +const demoUrls = { + gist1: 'https://gist.github.com/jokester/983bfc399f4d6e5d677774c054250a94', + githubRaw1: 'https://raw.githubusercontent.com/jokester/slides.ihate.work/main/README.md', +} as const; export function ExternalSourceInput(props: { onSubmit?(url: string): void }) { const [urlValue, setUrlValue] = useState(''); - const [loading, setLoading] = useToggle(false); - const detectedType = detectSourceUrlType(urlValue); + const urlType = detectSourceUrlType(urlValue); const onSubmit = () => { - if (!isUrl(urlValue)) { + if (urlType === 'invalid') { alert('Invalid URL'); return; } @@ -23,7 +26,7 @@ export function ExternalSourceInput(props: { onSubmit?(url: string): void }) {
setUrlValue(ev.target.value)} @@ -32,6 +35,13 @@ export function ExternalSourceInput(props: { onSubmit?(url: string): void }) { onSubmit(); } }} + InputProps={{ + endAdornment: urlValue && ( + setUrlValue('')}> + + + ), + }} />
@@ -41,26 +51,37 @@ export function ExternalSourceInput(props: { onSubmit?(url: string): void }) { variant="outlined" color="primary" onClick={onSubmit} - aria-busy={loading} - disabled={loading || !urlValue} + disabled={!urlValue} > { - loading ? ( - 'Loading...' - ) : !detectedType ? ( - 'Load' // but disabled - ) : detectedType === 'gist' ? ( + urlType === 'gist' ? ( <> - Load from Github Gist + Load from Gist   + + + ) : urlType === 'unknown' ? ( + <> + Load from HTTP URL   + - ) : detectedType === 'unknown' ? ( - 'Load Raw URL' ) : ( 'Load' ) // fallback }
+
+ Examples: +
+ + / + +
+
); } diff --git a/web/src/components/useRenderSwr.tsx b/web/src/components/useRenderSwr.tsx index 645b4b3..54b2d1c 100644 --- a/web/src/components/useRenderSwr.tsx +++ b/web/src/components/useRenderSwr.tsx @@ -2,15 +2,20 @@ import { SWRResponse } from 'swr'; import { ReactElement } from 'react'; import { extractErrorMessage } from '../utils'; +function _renderError(swrError: SWRResponse['error']): string | ReactElement { + return extractErrorMessage(swrError); +} + export function useRenderSwr( res: SWRResponse, onValue: (d: Data) => ReactElement, + renderError: typeof _renderError = _renderError, ): string | ReactElement { if (res.isLoading) { return 'Loading...'; } if (res.error) { - return extractErrorMessage(res.error); + return renderError(res.error); } return onValue(res.data!); } diff --git a/web/src/core/GistSource.spec.ts b/web/src/core/GistSource.spec.ts new file mode 100644 index 0000000..eb21b8d --- /dev/null +++ b/web/src/core/GistSource.spec.ts @@ -0,0 +1,17 @@ +import { parseGistUrl } from './GistSource'; + +describe('GistSource', () => { + describe('parseGistUrl', () => { + it('should parse gist url', () => { + for (const url of [ + 'https://gist.githubusercontent.com/jokester/2ae304016d8c25b09a68a9221f6c07c8/raw/4676f49e32f95fd76549ce4f7330f0f7aa4662b3/0-rancher-zerotier-k3s-deployment.md', + 'https://gist.github.com/jokester/2ae304016d8c25b09a68a9221f6c07c8', + 'https://gist.github.com/jokester/2ae304016d8c25b09a68a9221f6c07c8/4676f49e32f95fd76549ce4f7330f0f7aa4662b3', + ]) { + const parsed = parseGistUrl(new URL(url)); + expect(parsed).toBeTruthy(); + expect(parsed).toMatchSnapshot(`parseGistUrl(${url})`); + } + }); + }); +}); diff --git a/web/src/core/GistSource.ts b/web/src/core/GistSource.ts index 033b9f8..0abeff6 100644 --- a/web/src/core/GistSource.ts +++ b/web/src/core/GistSource.ts @@ -1,63 +1,104 @@ import { Octokit } from '@octokit/rest'; import { SlideBundle } from './SlideBundle'; import { SourceError } from './errors'; -import { isUrl } from './url-loader'; +import { InternalUrlProvider } from './internal-url-provider'; -interface GistSourceLocator { +export interface GistSourceLocator { + /** + * @deprecated don't use this for URL at Gist. This could be a intra-site URL. + */ + rawUrl: URL; ownerId: string; gistId: string; revisionId?: string; + filename?: string; +} + +export function buildGistSource(url: URL): GistSource | null { + const l = parseGistUrl(url); + return l && new GistSource(l); } -function parseUrl(u: URL): GistSourceLocator | null { +export function parseGistUrl(u: URL): GistSourceLocator | null { const parts = u.pathname.split('/'); - const [_empty, ownerId, gistId, revisionId, ...rest] = parts; - if (gistId?.length === 32 && !revisionId) { + const [_empty, ownerId, gistId, ...rest] = parts; + if (!(ownerId && gistId?.length === 32)) { + return null; + } + if (rest.length === 3 && rest[0] === 'raw') { + // a raw file like + // https://gist.githubusercontent.com/jokester/2ae304016d8c25b09a68a9221f6c07c8/raw/4676f49e32f95fd76549ce4f7330f0f7aa4662b3/0-rancher-zerotier-k3s-deployment.md return { + rawUrl: u, ownerId, gistId, + revisionId: rest[1], + filename: rest[2], }; } - // TODO: support more patterns - return null; -} -export class GistSource { - static isGistUrl(url: string): boolean { - return isUrl(url) && !!parseUrl(new URL(url)); + if (rest.length === 1) { + // a gist like + // https://gist.github.com/jokester/2ae304016d8c25b09a68a9221f6c07c8 + return { + rawUrl: u, + ownerId, + gistId, + revisionId: rest[0], + }; } - readonly locator: Readonly; + if (!rest.length) { + // a gist revision like: + // https://gist.github.com/jokester/2ae304016d8c25b09a68a9221f6c07c8/4676f49e32f95fd76549ce4f7330f0f7aa4662b3 + return { + rawUrl: u, + ownerId, + gistId, + }; + } + return null; +} - /** - * @param gistUrl like: - * - https://gist.github.com/jokester/2ae304016d8c25b09a68a9221f6c07c8 - * => page: gist - * - https://gist.github.com/jokester/2ae304016d8c25b09a68a9221f6c07c8/4676f49e32f95fd76549ce4f7330f0f7aa4662b3 - * => page: gist revision - * - https://gist.githubusercontent.com/jokester/2ae304016d8c25b09a68a9221f6c07c8/raw/4676f49e32f95fd76549ce4f7330f0f7aa4662b3/0-rancher-zerotier-k3s-deployment.md - * => an raw file - * @param client - */ +export class GistSource implements InternalUrlProvider { + readonly locator: Readonly; constructor( - readonly gistUrl: string, + locator: string | URL | GistSourceLocator, private readonly client = new Octokit(), ) { - this.locator = parseUrl(new URL(gistUrl))!; + if (typeof locator === 'string') { + this.locator = parseGistUrl(new URL(locator))!; + } else if (locator instanceof URL) { + this.locator = parseGistUrl(locator)!; + } else { + this.locator = locator; + } + if (!this.locator) { + throw new Error(`invalid github URL`); + } } get fetchKey(): string { - return `gistId=${this.gistUrl}`; + return `gistUrl=${this.locator.rawUrl}`; } - get pageUrl(): string { - const parsed = this.locator; + asInternalPageUrl(): string { + const { rawUrl } = this.locator; + return `/gist${rawUrl.pathname}`; + } + + asUpstreamUrl(): string { + const { ownerId, gistId, revisionId } = this.locator; - return `https://gist.github.com/${parsed.ownerId}/${parsed.gistId}`; + if (revisionId) { + return `https://gist.github.com/${ownerId}/${gistId}/${revisionId}`; + } else { + return `https://gist.github.com/${ownerId}/${gistId}`; + } } /** - * @return a asset bundle fetched from github + * @return a asset bundle fetched from GitHub */ async fetchSource(): Promise { const res = await this.client.rest.gists.get({ diff --git a/web/src/core/SlideBundle.ts b/web/src/core/SlideBundle.ts index dc5cacd..a0c0694 100644 --- a/web/src/core/SlideBundle.ts +++ b/web/src/core/SlideBundle.ts @@ -5,5 +5,6 @@ export interface SlideBundle { // markdown text slideText: string; fetchTextSource?: FetchTextSource; + localTextSource?: Blob; gistSource?: GistSource; } diff --git a/web/src/core/__snapshots__/GistSource.spec.ts.snap b/web/src/core/__snapshots__/GistSource.spec.ts.snap new file mode 100644 index 0000000..3d6e53a --- /dev/null +++ b/web/src/core/__snapshots__/GistSource.spec.ts.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GistSource parseGistUrl should parse gist url: parseGistUrl(https://gist.github.com/jokester/2ae304016d8c25b09a68a9221f6c07c8) 1`] = ` +{ + "gistId": "2ae304016d8c25b09a68a9221f6c07c8", + "ownerId": "jokester", + "rawUrl": "https://gist.github.com/jokester/2ae304016d8c25b09a68a9221f6c07c8", +} +`; + +exports[`GistSource parseGistUrl should parse gist url: parseGistUrl(https://gist.github.com/jokester/2ae304016d8c25b09a68a9221f6c07c8/4676f49e32f95fd76549ce4f7330f0f7aa4662b3) 1`] = ` +{ + "gistId": "2ae304016d8c25b09a68a9221f6c07c8", + "ownerId": "jokester", + "rawUrl": "https://gist.github.com/jokester/2ae304016d8c25b09a68a9221f6c07c8/4676f49e32f95fd76549ce4f7330f0f7aa4662b3", + "revisionId": "4676f49e32f95fd76549ce4f7330f0f7aa4662b3", +} +`; + +exports[`GistSource parseGistUrl should parse gist url: parseGistUrl(https://gist.githubusercontent.com/jokester/2ae304016d8c25b09a68a9221f6c07c8/raw/4676f49e32f95fd76549ce4f7330f0f7aa4662b3/0-rancher-zerotier-k3s-deployment.md) 1`] = ` +{ + "filename": "0-rancher-zerotier-k3s-deployment.md", + "gistId": "2ae304016d8c25b09a68a9221f6c07c8", + "ownerId": "jokester", + "rawUrl": "https://gist.githubusercontent.com/jokester/2ae304016d8c25b09a68a9221f6c07c8/raw/4676f49e32f95fd76549ce4f7330f0f7aa4662b3/0-rancher-zerotier-k3s-deployment.md", + "revisionId": "4676f49e32f95fd76549ce4f7330f0f7aa4662b3", +} +`; diff --git a/web/src/core/internal-url-provider.ts b/web/src/core/internal-url-provider.ts new file mode 100644 index 0000000..ddc3ed1 --- /dev/null +++ b/web/src/core/internal-url-provider.ts @@ -0,0 +1,4 @@ +export interface InternalUrlProvider { + asInternalPageUrl(): string; + asUpstreamUrl(): string; +} diff --git a/web/src/core/url-loader.ts b/web/src/core/url-loader.ts index 82674e0..038a20b 100644 --- a/web/src/core/url-loader.ts +++ b/web/src/core/url-loader.ts @@ -1,41 +1,17 @@ -import { SlideBundle } from './SlideBundle'; -import { GistSource } from './GistSource'; -import { FetchTextSource } from './FetchTextSource'; +import { parseGistUrl } from './GistSource'; export function isUrl(u: unknown): u is string { - try { - new URL(u as string); - return true; - } catch { - return false; - } + return typeof u === 'string' && URL.canParse(u); } -type SourceType = 'gist' | 'unknown' | null; +type SourceType = 'gist' | 'unknown' | 'invalid'; export function detectSourceUrlType(url: unknown): SourceType { if (!isUrl(url)) { - return null; + return 'invalid'; } - if (GistSource.isGistUrl(url)) { + if (parseGistUrl(new URL(url))) { return 'gist'; } return 'unknown'; } - -export async function loadExternalSourceUrl(url: string): Promise { - if (!isUrl(url)) { - throw new Error('Invalid URL'); - } - if (GistSource.isGistUrl(url)) { - const src = new GistSource(url); - return await src.fetchSource(); - } - - // fallback: assuming the URL is a CORS-capable markdown file - const res = await fetch(url).then((res) => res.text()); - return { - slideText: res, - fetchTextSource: new FetchTextSource(url), - }; -} diff --git a/web/src/gist/gist-textarea.tsx b/web/src/gist/gist-textarea.tsx index af5e9bf..750b975 100644 --- a/web/src/gist/gist-textarea.tsx +++ b/web/src/gist/gist-textarea.tsx @@ -1,7 +1,5 @@ import { SlideBundle } from '../core/SlideBundle'; import { ReactElement, useState } from 'react'; -import clsx from 'clsx'; -import { Button } from '@mui/material'; import { MarkdownTextarea } from '../markdown/markdown-textarea'; export function GistTextarea(props: { @@ -11,12 +9,5 @@ export function GistTextarea(props: { onStart?(markdownText: string): void; }): ReactElement { const [text, setText] = useState(props.initialValue || props.bundle.slideText); - return ( - props.onStart?.(text)} - className={props.className} - /> - ); + return ; } diff --git a/web/src/layouts/index.tsx b/web/src/layouts/index.tsx index bc22a10..254fd9b 100644 --- a/web/src/layouts/index.tsx +++ b/web/src/layouts/index.tsx @@ -1,27 +1,55 @@ import Link from 'next/link'; import { Fragment, PropsWithChildren } from 'react'; +import { Help, Info, PresentToAll, GitHub } from '@mui/icons-material'; +import { Button } from '@mui/material'; +import { globalStyles } from './theme'; +import clsx from 'clsx'; export function PageContainer(props: PropsWithChildren) { - return
{props.children}
; + return
{props.children}
; } export function PageHeader() { return ( <> -

- - slides.ihate.work +
+

+ +   slides.ihate.work + +

+    +

Present Markdown slides

+
+
+ + + -

-

A site to present Markdown slides

-
+ + + + + + +
+
); } -export function PageFooter() { +export function CreditsFooter() { return ( -