diff --git a/website/raw-plugin.js b/website/raw-plugin.js index 8c6b49bc6240..7ad401083f64 100644 --- a/website/raw-plugin.js +++ b/website/raw-plugin.js @@ -5,10 +5,11 @@ module.exports = function (context, options) { configureWebpack(config, isServer, utils) { return { ignoreWarnings: [ - // Suppress warning about dynamic import expressions in monaco-init.ts - // This is expected behavior for dynamic TypeScript definition loading + // Suppress warning about dynamic import expressions in + // Playground/monaco/typeLibs.ts. This is expected behavior for + // dynamic TypeScript definition loading { - module: /monaco-init\.ts/, + module: /Playground[\\/]monaco[\\/]typeLibs\.ts/, message: /Critical dependency: the request of a dependency is an expression/, }, diff --git a/website/scripts/generateMonacoPreloads.cjs b/website/scripts/generateMonacoPreloads.cjs index bfe383786d5c..22908bc9993a 100644 --- a/website/scripts/generateMonacoPreloads.cjs +++ b/website/scripts/generateMonacoPreloads.cjs @@ -22,7 +22,7 @@ const VS_ROOT = path.dirname( const MONACO_ROOT = path.resolve(VS_ROOT, '../..'); const MANIFEST_PATH = path.join( WEBSITE_ROOT, - 'src/components/Playground/monacoPreloadManifest.ts', + 'src/components/Playground/monaco/preloadManifest.ts', ); /** @returns {{ monacoVersion: string, preloadPaths: string[], prefetchPaths: string[] }} */ diff --git a/website/src/components/DiffEditorMonaco.tsx b/website/src/components/DiffEditorMonaco.tsx index 878a5e43e9d4..a88cf26abc91 100644 --- a/website/src/components/DiffEditorMonaco.tsx +++ b/website/src/components/DiffEditorMonaco.tsx @@ -5,12 +5,13 @@ import { type editor } from 'monaco-editor'; import { useMemo } from 'react'; import type { CodeDocument } from './Playground/editor/codeModel'; -import { extensionToMonacoLanguage } from './Playground/extensionToMonacoLanguage'; -import './Playground/monaco-init'; -import { isMobileOrBot } from './Playground/isMobileOrBot'; -import { options } from './Playground/monacoOptions'; +import { extensionToMonacoLanguage } from './Playground/monaco/language'; +import { options } from './Playground/monaco/options'; +import './Playground/monaco/setup'; +import { MONACO_THEME } from './Playground/monaco/theme'; +import useAutoHeight from './Playground/monaco/useAutoHeight'; import styles from './Playground/styles.module.css'; -import useAutoHeight from './Playground/useAutoHeight'; +import { isMobileOrBot } from './Playground/userAgent'; export default function DiffEditor({ documents, fallback }: DiffMonacoProps) { const [original, modified] = useMemo( @@ -48,7 +49,7 @@ export default function DiffEditor({ documents, fallback }: DiffMonacoProps) { options={DIFF_OPTIONS} onMount={handleMount} height={height} - theme="prism" + theme={MONACO_THEME} loading={fallback} // Prevent "TextModel got disposed before DiffEditorWidget model got reset" error // by not letting @monaco-editor/react manage model disposal diff --git a/website/src/components/HooksPlayground.tsx b/website/src/components/HooksPlayground.tsx index 756ac53125cf..35ba60b2d77f 100644 --- a/website/src/components/HooksPlayground.tsx +++ b/website/src/components/HooksPlayground.tsx @@ -1,8 +1,8 @@ -import type { Fixture, Interceptor } from '@data-client/test'; import React, { memo } from 'react'; -import Playground from './Playground'; +import Playground, { type PlaygroundProps } from './Playground'; +/** MDX entry point: wraps one or more code fences in a live Playground. */ const HooksPlayground = ({ children, groupId, @@ -24,6 +24,7 @@ const HooksPlayground = ({ defaultTab={defaultTab} headerControls={headerControls} > + {/* A single fence arrives as one
 element; unwrap it to its  */}
     {typeof children === 'string' ?
       children
     : Array.isArray(children) ?
@@ -34,15 +35,3 @@ const HooksPlayground = ({
   
 );
 export default memo(HooksPlayground);
-
-interface PlaygroundProps {
-  groupId: string;
-  defaultOpen?: 'y' | 'n';
-  row: boolean;
-  hidden?: boolean;
-  fixtures?: (Fixture | Interceptor)[];
-  getInitialInterceptorData?: () => T;
-  children: React.ReactNode;
-  defaultTab?: string;
-  headerControls?: React.ReactNode;
-}
diff --git a/website/src/components/Playground/PlaygroundEditor.tsx b/website/src/components/Playground/PlaygroundEditor.tsx
index c9a299b96c2a..e9796fbb16bd 100644
--- a/website/src/components/Playground/PlaygroundEditor.tsx
+++ b/website/src/components/Playground/PlaygroundEditor.tsx
@@ -1,9 +1,9 @@
+import InteractiveEditor from './editor/InteractiveEditor';
 import PlaygroundLiveEditor from './PlaygroundLiveEditor';
-import PlaygroundMonacoEditor from './PlaygroundMonacoEditor';
 
 // Always use Monaco editor wrapper - mobile/bot check happens inside
 // This avoids hydration mismatch from module-level navigator checks
-const PlaygroundEditor = PlaygroundMonacoEditor;
+const PlaygroundEditor = InteractiveEditor;
 export default PlaygroundEditor;
 
 // Re-export for use as fallback inside Monaco editor
diff --git a/website/src/components/Playground/PlaygroundMonacoEditor.tsx b/website/src/components/Playground/PlaygroundMonacoEditor.tsx
deleted file mode 100644
index 574b765c7e93..000000000000
--- a/website/src/components/Playground/PlaygroundMonacoEditor.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-import Editor from '@monaco-editor/react';
-import type { ISelection } from 'monaco-editor';
-import type * as Monaco from 'monaco-editor';
-import rangeParser from 'parse-numeric-range';
-import { memo, useCallback, useMemo } from 'react';
-import { LiveEditor } from 'react-live';
-import './monaco-init';
-
-import { extensionToMonacoLanguage } from './extensionToMonacoLanguage';
-import { isMobileOrBot } from './isMobileOrBot';
-import { options } from './monacoOptions';
-import PlaygroundLiveEditor from './PlaygroundLiveEditor';
-import useAutoHeight from './useAutoHeight';
-
-// TODO: consider using the ts worker's getEmitOutput to compile rather than babel
-
-function PlaygroundMonacoEditor({
-  onChange,
-  code,
-  path = '',
-  onFocus,
-  tabIndex,
-  highlights,
-  autoFocus = false,
-  isFocused = false,
-  language = 'tsx',
-  readOnly = false,
-}: {
-  onChange: (value: string | undefined) => void;
-  code: string;
-  path?: string;
-  onFocus: (tabIndex: number) => void;
-  tabIndex: number;
-  highlights?: string;
-  autoFocus?: boolean;
-  isFocused?: boolean;
-  language?: string;
-  readOnly?: boolean;
-}) {
-  const editorOptions = useMemo(() => ({ ...options, readOnly }), [readOnly]);
-  const { height, handleMount: handleAutoMount } = useAutoHeight({
-    initialContentHeight: code.split('\n').length * editorOptions.lineHeight,
-    isFocused,
-  });
-
-  const handleMount = useCallback((editor: Monaco.editor.ICodeEditor) => {
-    // autofocus
-    if (autoFocus) editor.focus();
-    // autohighlight
-    const myhighlights = highlights ? rangeParser(highlights) : undefined;
-
-    if (myhighlights) {
-      let selectionStartLineNumber = myhighlights[0];
-      let positionLineNumber = selectionStartLineNumber;
-      const selections: ISelection[] = [];
-      myhighlights.forEach(lineNumber => {
-        // more of same selection
-        if (lineNumber === positionLineNumber) {
-          positionLineNumber++;
-        } else {
-          selections.push({
-            selectionStartLineNumber,
-            selectionStartColumn: 0,
-            positionLineNumber,
-            positionColumn: 0,
-          });
-          selectionStartLineNumber = lineNumber;
-          positionLineNumber = lineNumber + 1;
-        }
-      });
-      selections.push({
-        selectionStartLineNumber,
-        selectionStartColumn: 0,
-        positionLineNumber,
-        positionColumn: 0,
-      });
-      editor.setSelections(selections);
-    }
-
-    // go to definition
-    editor.onDidFocusEditorText(() => {
-      onFocus(tabIndex);
-    });
-
-    handleAutoMount(editor);
-  }, []);
-
-  // loading only shows the initial snapshot, so it need not track code changes
-  const loading = useMemo(
-    () => ,
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-    [language],
-  );
-
-  // Use lightweight editor for mobile/bots
-  // This runs inside BrowserOnly, so navigator is always defined
-  // Check must be after hooks to satisfy React's rules of hooks
-  if (isMobileOrBot()) {
-    return ;
-  }
-
-  return (
-    
-  );
-}
-export default memo(PlaygroundMonacoEditor);
diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md
new file mode 100644
index 000000000000..abc0ff64da35
--- /dev/null
+++ b/website/src/components/Playground/README.md
@@ -0,0 +1,131 @@
+# Playground
+
+Live, editable code examples for the docs, blog and homepage. Read this before
+changing anything here: most of the complexity exists to keep pages fast,
+indexable and hydration-safe, and those behaviors are easy to drop silently.
+
+## Entry points
+
+| Component                                                                                   | Used by                        | What it renders                         |
+| ------------------------------------------------------------------------------------------- | ------------------------------ | --------------------------------------- |
+| `HooksPlayground` (`../HooksPlayground.tsx`) → `Playground` (`index.tsx`)                   | MDX docs/blog, homepage `Demo` | Editor + live preview + Store inspector |
+| `TypeScriptEditor` (`../TypeScriptEditor.tsx`)                                              | MDX                            | Editor only (`variant="standalone"`)    |
+| `EndpointPlayground` (`../HTTP/EndpointPlayground.tsx`)                                     | MDX                            | Editor + static HTTP request/response   |
+| `DiffEditor` (`../DiffEditor.tsx` → `../DiffEditorChooser.tsx` → `../DiffEditorMonaco.tsx`) | MDX                            | Read-only Monaco diff of two fences     |
+| `Demo/CodeEditor` (`../Demo/CodeEditor.tsx`)                                                | Homepage                       | One `HooksPlayground` per protocol tab  |
+
+## Layout
+
+```
+index.tsx           Playground: code model, editor/preview ordering, hidden handling
+userAgent.ts        isBot / isMobileOrBot gates
+PlaygroundEditor.tsx  editor entry used by EditorSurface (aliases editor/InteractiveEditor)
+PlaygroundLiveEditor.tsx  editable react-live editor (mobile/bot fallback)
+resources/          sample resources (currently unreferenced; kept for future demos)
+Boundary.tsx        BrowserOnly + Suspense with one fallback for SSR and loading
+Header.tsx, TabList.tsx, styles.module.css   shared chrome (also used by HTTP/, Demo/)
+editor/             code model + editor UI
+  codeModel.ts        parse MDX fences/strings into documents; edits
+  EditorShell.tsx     react-live LiveProvider supplying the Prism theme
+  EditorSurface.tsx   headers, file tabs, collapse state, per-file TextEditTab
+  StaticEditor.tsx    SSR/crawler/loading markup (Prism, not editable)
+  InteractiveEditor.tsx  client-only: Monaco, or editable react-live on mobile/bots
+monaco/             everything Monaco-specific
+  setup.ts            side-effect init: CDN loader, hints, compiler options, theme, types
+  typeLibs.ts         raw .d.ts chunks from editor-types/ → addExtraLib
+  navigation.ts       cross-tab go-to-definition + import completions
+  modelPath.ts        `//` model URI scheme
+  preloadManifest.ts  GENERATED by website/scripts/generateMonacoPreloads.cjs
+  resourceHints.ts, theme.ts, options.ts, language.ts, useAutoHeight.ts, highlightSelections.ts
+preview/            live execution (loaded lazily, never on the server)
+  LivePreview.tsx     react-live LiveProvider with scope + transformCode
+  Preview.tsx         DataProvider + MockResolver(fixtures) + Store inspector
+  ...
+editor-types/       .d.ts bundles fed to Monaco (raw-loader)
+DesignSystem/       components injected into preview scope
+```
+
+## Invariants
+
+### SSR / SSG and crawlers
+
+- Open files' source is in the static HTML: `TextEditTab` renders
+  `StaticEditor` on the server and during hydration (BrowserOnly fallback).
+  Collapsed/unselected files render nothing until opened.
+- The preview renders `previewLoading` (empty frame + Store toggle) on the
+  server, while loading, for bots, and while `hidden`.
+- Fixtures render server-side as JSON `CodeBlock`s; function responses are
+  BrowserOnly (a stringified function differs between server and client
+  bundles).
+- Never branch on `navigator`/user agent during render outside `BrowserOnly`
+  (hydration mismatch). `isBot` is module-level and only read in the
+  `lazy()` factory (and in StackBlitz after intersection); `isMobileOrBot()`
+  is only called inside BrowserOnly or in `monaco/setup.ts`.
+
+### Incremental JS delivery
+
+- `monaco/setup.ts` runs once when the editor chunk evaluates: injects
+  preload/prefetch ``s for the version-matched CDN files, starts
+  `loader.init()` and the type-lib chunk downloads in parallel.
+  `preloadManifest.ts` is regenerated on every docusaurus config load.
+- Type libs: one webpack chunk per third-party `.d.ts` (`reactDTS`, …) and a
+  single `lazy-once` chunk for `editor-types/@data-client/*`. Failed fetches
+  degrade to empty libs. `website/raw-plugin.js` silences the expected
+  "expression in import" warning for `monaco/typeLibs.ts` by path.
+- Preview code is a lazy chunk (`PreviewWithScope`, prefetched) and
+  `PreviewBlock` a nested lazy chunk (preloaded with its parent).
+- Mobile and bots download none of Monaco or the type libs.
+
+### Mobile and bots
+
+- Mobile: editable react-live editor instead of Monaco; live preview still runs.
+- Bots: same editor fallback; preview stays `previewLoading` (never loads
+  `LivePreview`); StackBlitz embeds never load.
+- `DiffEditor` shows a two-`CodeBlock` grid instead of Monaco.
+
+### Hidden playgrounds (homepage Demo protocol tabs)
+
+- All protocols stay mounted so every protocol's source is in the HTML.
+- `hidden` keeps `StaticEditor` (no Monaco) until first shown, then latches
+  interactive so undo history and go-to-definition survive tab switches.
+- The live preview unmounts while hidden (its store resets).
+
+### Editor
+
+- Fence metastring: `title="…"`, `path="…"` (quotes optional), `collapsed`, `column`, `{1-3}`
+  highlight ranges (pre-selected in Monaco); `language-*` class. Element props
+  override metastring values (Demo passes them directly). `defaultTab`
+  overrides `collapsed`.
+- `row` layout: non-`column` files are tabs (focus switches tab); `column`
+  files stack with collapsible headers. Stacked layout: every titled file gets a header,
+  collapsible when there are several files or fixtures.
+- Collapsed/unselected files stay mounted (`display: none`), never unmounted:
+  their Monaco models must exist for cross-file types, go-to-definition and
+  to keep edits. Monaco is uncontrolled (`defaultValue`), so re-renders never
+  reset the buffer.
+- Fixtures are listed above the editor in stacked layout only.
+- Go-to-definition across files: focusing the target editor reveals its tab.
+  Relies on the `//` model URIs (`monaco/modelPath.ts`).
+- Relative import completions list only the other files of the same
+  playground (same model id).
+- Handlers passed to `InteractiveEditor` are referentially stable so unedited
+  tabs skip re-rendering on keystrokes.
+- Height follows Monaco content size and re-measures when a tab is shown.
+
+### Preview
+
+- All documents are concatenated (deferred with `useDeferredValue`),
+  stripped of imports/exports by `transformCode`, and run `noInline`
+  (`render()`) with `preview/scope.ts` as globals. Keep
+  `monaco/typeLibs.ts` declarations in sync with the scope.
+- Each playground gets its own `DataProvider` store (`MockResolver` serves
+  `fixtures`); `memo(Preview)` keeps it from re-rendering on code edits.
+- Store inspector open state persists per `groupId` via tab storage and
+  avoids scroll jumps; in `row` layout it replaces the result while open.
+- Third-party console noise is demoted only while previews are mounted
+  (`usePlaygroundConsoleDemotion`); never add first-party matchers.
+
+## Tests
+
+`yarn test --selectProjects ReactDOM --testPathPatterns website/src/components/Playground`
+(CI persists only this directory of the website for these tests.)
diff --git a/website/src/components/Playground/editor/EditorShell.tsx b/website/src/components/Playground/editor/EditorShell.tsx
index 2b634d720d8b..864c9010d353 100644
--- a/website/src/components/Playground/editor/EditorShell.tsx
+++ b/website/src/components/Playground/editor/EditorShell.tsx
@@ -1,7 +1,7 @@
 import React from 'react';
 import { LiveProvider } from 'react-live';
 
-import { useReactLiveTheme } from '../useReactLiveTheme';
+import { useReactLiveTheme } from './useReactLiveTheme';
 
 export default function EditorShell({
   children,
diff --git a/website/src/components/Playground/editor/EditorSurface.tsx b/website/src/components/Playground/editor/EditorSurface.tsx
index 48a7d4a58a6d..4f1a9a46a8cf 100644
--- a/website/src/components/Playground/editor/EditorSurface.tsx
+++ b/website/src/components/Playground/editor/EditorSurface.tsx
@@ -1,20 +1,16 @@
 import BrowserOnly from '@docusaurus/BrowserOnly';
 import Translate from '@docusaurus/Translate';
 import clsx from 'clsx';
-import React, {
-  type ComponentProps,
-  useCallback,
-  useMemo,
-  useRef,
-  useState,
-} from 'react';
-import { LiveEditor } from 'react-live';
+import React, { useCallback, useMemo, useRef, useState } from 'react';
 
 import Header from '../Header';
+import { modelPath, useModelId } from '../monaco/modelPath';
 import Editor from '../PlaygroundEditor';
 import styles from '../styles.module.css';
 import TabList from '../TabList';
 import type { CodeDocument, CodeModel } from './codeModel';
+import type { InteractiveEditorProps } from './InteractiveEditor';
+import StaticEditor from './StaticEditor';
 
 export interface EditorSurfaceProps extends CodeModel {
   layout: 'row' | 'stacked';
@@ -34,7 +30,7 @@ export default function EditorSurface({
   fixtureContent,
   headerControls,
 }: EditorSurfaceProps) {
-  const id = useNumericId();
+  const id = useModelId();
   const row = layout === 'row';
   const [closedList, setClosed] = useState(() =>
     documents.map(({ collapsed }) => collapsed),
@@ -42,7 +38,7 @@ export default function EditorSurface({
 
   // Document count and col flags are fixed after the initial parse, so
   // capturing them once keeps these handlers referentially stable. That
-  // stability is what lets memo(PlaygroundMonacoEditor) skip re-rendering
+  // stability is what lets memo(InteractiveEditor) skip re-rendering
   // unedited tabs on every keystroke.
   const colFlags = useRef(documents.map(({ col }) => col)).current;
   const handleTabSwitch = useCallback(
@@ -114,7 +110,7 @@ export default function EditorSurface({
             }
             onChange={handleChanges[index]}
             code={document.value}
-            path={`/${id}/${document.path}`}
+            path={modelPath(id, document.path)}
             isFocused={!closedList[index]}
             language={document.language}
             highlights={document.highlights}
@@ -129,32 +125,17 @@ export default function EditorSurface({
 function TextEditTab({
   hidden,
   interactive,
-  code,
-  language,
-  tabIndex,
-  ...rest
-}: ComponentProps & {
+  ...editorProps
+}: InteractiveEditorProps & {
   hidden: boolean;
   interactive: boolean;
 }) {
-  // Stable across SSR/hydration (do not branch on navigator / isGoogleBot here).
-  const staticView = ;
-
-  // Deferred protocols: keep open-file source in the DOM for SSG/crawlers;
-  // skip Monaco until the sandbox has been shown once.
-  if (!interactive) {
-    return (
-      
- {hidden ? null : staticView} -
+ // SSR + hydration markup: open tabs' source stays in the HTML for crawlers. + // Never branch on navigator / user agent outside BrowserOnly. + const staticView = + hidden ? null : ( + ); - } - - const fallback = hidden ? <> : staticView; return (
- - {() => ( - - )} - + {/* Not yet interactive (e.g. never-shown Demo tab): skip Monaco entirely */} + {interactive ? + + {() => } + + : staticView}
); } @@ -269,11 +246,3 @@ function EditorHeader({ ); } - -// Monaco model URIs are matched with /\/\d+\// in monaco-init.ts. -function useNumericId() { - return useMemo( - () => Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(), - [], - ); -} diff --git a/website/src/components/Playground/editor/InteractiveEditor.tsx b/website/src/components/Playground/editor/InteractiveEditor.tsx new file mode 100644 index 000000000000..0402ed7e5359 --- /dev/null +++ b/website/src/components/Playground/editor/InteractiveEditor.tsx @@ -0,0 +1,99 @@ +import Editor from '@monaco-editor/react'; +import type * as Monaco from 'monaco-editor'; +import rangeParser from 'parse-numeric-range'; +import { memo, useCallback, useMemo } from 'react'; + +import '../monaco/setup'; +import { highlightSelections } from '../monaco/highlightSelections'; +import { extensionToMonacoLanguage } from '../monaco/language'; +import { options } from '../monaco/options'; +import { MONACO_THEME } from '../monaco/theme'; +import useAutoHeight from '../monaco/useAutoHeight'; +import PlaygroundLiveEditor from '../PlaygroundLiveEditor'; +import { isMobileOrBot } from '../userAgent'; +import StaticEditor from './StaticEditor'; + +// TODO: consider using the ts worker's getEmitOutput to compile rather than babel + +export interface InteractiveEditorProps { + onChange: (value: string | undefined) => void; + code: string; + /** Monaco model path (see ../monaco/modelPath.ts) */ + path?: string; + /** Called with tabIndex whenever this editor gains focus */ + onFocus: (tabIndex: number) => void; + tabIndex: number; + /** Code-fence line ranges to pre-select, e.g. `1-3,5` */ + highlights?: string; + autoFocus?: boolean; + /** Whether this tab is visible; re-measures height when it becomes so */ + isFocused?: boolean; + language: string; + readOnly?: boolean; +} + +/** + * Client-only (render inside BrowserOnly): Monaco on desktop, an editable + * react-live editor on mobile/bots. + */ +function InteractiveEditor({ + onChange, + code, + path = '', + onFocus, + tabIndex, + highlights, + autoFocus = false, + isFocused = false, + language, + readOnly = false, +}: InteractiveEditorProps) { + const editorOptions = useMemo(() => ({ ...options, readOnly }), [readOnly]); + const { height, handleMount: handleAutoMount } = useAutoHeight({ + initialContentHeight: code.split('\n').length * editorOptions.lineHeight, + isFocused, + }); + + // Mount-time setup only: props read here are fixed for the editor's life + const handleMount = useCallback((editor: Monaco.editor.ICodeEditor) => { + if (autoFocus) editor.focus(); + if (highlights) { + const selections = highlightSelections(rangeParser(highlights)); + if (selections.length) editor.setSelections(selections); + } + // Focus reveals this tab (also how cross-tab go to definition lands) + editor.onDidFocusEditorText(() => { + onFocus(tabIndex); + }); + handleAutoMount(editor); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // loading only shows the initial snapshot, so it need not track code changes + const loading = useMemo( + () => , + // eslint-disable-next-line react-hooks/exhaustive-deps + [language], + ); + + // After hooks to satisfy the rules of hooks; safe because we only render + // client-side, so navigator is defined and hydration is already done. + if (isMobileOrBot()) { + return ; + } + + return ( + + ); +} +export default memo(InteractiveEditor); diff --git a/website/src/components/Playground/editor/StaticEditor.tsx b/website/src/components/Playground/editor/StaticEditor.tsx new file mode 100644 index 000000000000..0a8a100fd8d8 --- /dev/null +++ b/website/src/components/Playground/editor/StaticEditor.tsx @@ -0,0 +1,16 @@ +import { LiveEditor } from 'react-live'; + +/** + * Prism-highlighted, non-editable code. Renders identically on server and + * client, so it is the SSG/crawler markup and the placeholder until Monaco + * mounts. Needs an EditorShell ancestor for its theme. + */ +export default function StaticEditor({ + code, + language, +}: { + code: string; + language: string; +}) { + return ; +} diff --git a/website/src/components/Playground/editor/__tests__/codeModel.test.tsx b/website/src/components/Playground/editor/__tests__/codeModel.test.tsx index 6039899a5e85..ac28293a1e6c 100644 --- a/website/src/components/Playground/editor/__tests__/codeModel.test.tsx +++ b/website/src/components/Playground/editor/__tests__/codeModel.test.tsx @@ -68,6 +68,20 @@ describe('code document model', () => { ]); }); + test('accepts unquoted title and path', () => { + expect( + parseCodeDocuments([ + code({ metastring: 'path=Todo.ts' }, 'a'), + code({ metastring: "title='T' path='src/b.ts' {1}" }, 'b'), + code({ metastring: 'title=c.ts' }, 'c'), + ]).map(({ path, title }) => [path, title]), + ).toEqual([ + ['Todo.ts', ''], + ['src/b.ts', 'T'], + ['c.ts', 'c.ts'], + ]); + }); + test('updates only the addressed document', () => { const documents = parseCodeDocuments([ code({ metastring: 'title="One"' }, 'one'), diff --git a/website/src/components/Playground/editor/codeModel.ts b/website/src/components/Playground/editor/codeModel.ts index 9e4dae422030..88bae079dc9e 100644 --- a/website/src/components/Playground/editor/codeModel.ts +++ b/website/src/components/Playground/editor/codeModel.ts @@ -16,6 +16,14 @@ export interface CodeModel { update: (index: number, value: string) => void; } +/** `key="value"`, `key='value'` or bare `key=value` from a fence metastring */ +function metaValue(metastring: string, key: string): string | undefined { + return new RegExp(`\\b${key}=(?:(["'])(.*?)\\1|([^\\s"']+))`) + .exec(metastring) + ?.slice(2) + .find(value => value !== undefined); +} + export function parseCodeDocuments( children: string | React.ReactNode | React.ReactNode[], defaultTab?: string, @@ -42,9 +50,7 @@ export function parseCodeDocuments( : child.props.children.props, ) .map(({ children: code, metastring = '', ...rest }) => { - const title = - metastring.match(/title=(?["'])(?.*?)\1/)?.groups - ?.title ?? ''; + const title = metaValue(metastring, 'title') ?? ''; const language = /\blanguage-(?<language>[\w-]+)/.exec(rest.className ?? '')?.groups ?.language ?? 'tsx'; @@ -61,7 +67,7 @@ export function parseCodeDocuments( : /\bcollapsed\b/.test(metastring), col: /\bcolumn\b/.test(metastring), path: - metastring.match(/path=(?<quote>["'])(?<path>.*?)\1/)?.groups?.path || + metaValue(metastring, 'path') || (fileBase.includes('.') ? fileBase : `${fileBase}.${extension}`), highlights: /\{([\d\-,.]+)\}/.exec(metastring)?.[1], language, diff --git a/website/src/components/Playground/useReactLiveTheme.tsx b/website/src/components/Playground/editor/useReactLiveTheme.ts similarity index 100% rename from website/src/components/Playground/useReactLiveTheme.tsx rename to website/src/components/Playground/editor/useReactLiveTheme.ts diff --git a/website/src/components/Playground/index.tsx b/website/src/components/Playground/index.tsx index 4ec2a4d9cae9..08ff50827bb0 100644 --- a/website/src/components/Playground/index.tsx +++ b/website/src/components/Playground/index.tsx @@ -7,22 +7,27 @@ import Boundary from './Boundary'; import { useCodeDocuments } from './editor/codeModel'; import EditorShell from './editor/EditorShell'; import EditorSurface from './editor/EditorSurface'; -import FixturePreview from './FixturePreview'; -import { isGoogleBot } from './isMobileOrBot'; +import FixturePreview from './preview/FixturePreview'; import type LivePreviewType from './preview/LivePreview'; -import PreviewWrapper from './PreviewWrapper'; -import { StoreToggle } from './StoreInspector'; +import PreviewWrapper from './preview/PreviewWrapper'; +import { StoreToggle } from './preview/StoreInspector'; import styles from './styles.module.css'; -import type { FixtureOrInterceptor, PreviewProps } from './types'; +import type { FixtureOrInterceptor } from './types'; +import { isBot } from './userAgent'; export interface PlaygroundProps<T = any> { children: React.ReactNode; + /** Tab-storage key for the Store inspector's open/closed choice */ groupId?: string; + /** Store inspector open by default */ defaultOpen?: 'y' | 'n'; + /** Tabs for files + editor beside preview (when wide enough) */ row?: boolean; + /** Inactive (e.g. unselected Demo tab): no Monaco until first shown, no preview */ hidden?: boolean; fixtures?: FixtureOrInterceptor<T>[]; getInitialInterceptorData?: () => T; + /** Title of the file tab to open; overrides `collapsed` metastrings */ defaultTab?: string; headerControls?: React.ReactNode; } @@ -38,51 +43,10 @@ export default function Playground<T>({ defaultTab, headerControls, }: PlaygroundProps<T>) { - const { - liveCodeBlock: { playgroundPosition }, - } = useDocusaurusContext().siteConfig.themeConfig as any; + const { playgroundPosition } = ( + useDocusaurusContext().siteConfig.themeConfig as any + ).liveCodeBlock; - return ( - <div - className={clsx(styles.playgroundQueryContainer, { - [styles.hidden]: hidden, - })} - > - <div - className={clsx(styles.playgroundContainer, { - [styles.row]: row, - })} - > - <PlaygroundContent - reverse={playgroundPosition === 'top'} - row={row} - hidden={hidden} - fixtures={fixtures} - groupId={groupId} - defaultOpen={defaultOpen} - getInitialInterceptorData={getInitialInterceptorData} - defaultTab={defaultTab} - headerControls={headerControls} - > - {children} - </PlaygroundContent> - </div> - </div> - ); -} - -function PlaygroundContent<T>({ - reverse, - children, - row, - hidden, - fixtures, - groupId, - defaultOpen, - defaultTab, - getInitialInterceptorData, - headerControls, -}: ContentProps<T>) { const model = useCodeDocuments(children, defaultTab); // Defer preview transpilation so editor input remains responsive. const code = useDeferredValue( @@ -113,7 +77,7 @@ function PlaygroundContent<T>({ const preview = hidden ? previewLoading : ( <Boundary key="preview" fallback={previewLoading}> - <PreviewWithScopeLazy + <LivePreview code={code} groupId={groupId} defaultOpen={defaultOpen} @@ -124,17 +88,24 @@ function PlaygroundContent<T>({ </Boundary> ); - return <>{reverse ? [preview, editor] : [editor, preview]}</>; -} - -interface ContentProps<T> extends PreviewProps<T> { - children: PlaygroundProps<T>['children']; - reverse: boolean; - hidden: boolean; - defaultTab?: string; - headerControls?: React.ReactNode; + return ( + <div + className={clsx(styles.playgroundQueryContainer, { + [styles.hidden]: hidden, + })} + > + <div + className={clsx(styles.playgroundContainer, { + [styles.row]: row, + })} + > + {playgroundPosition === 'top' ? [preview, editor] : [editor, preview]} + </div> + </div> + ); } +/** SSR, crawler, hidden and loading state: empty preview frame + Store toggle */ const previewLoading = ( <PreviewWrapper key="preview"> <div className={styles.playgroundPreview} /> @@ -142,8 +113,8 @@ const previewLoading = ( </PreviewWrapper> ); -const PreviewWithScopeLazy = lazy<typeof LivePreviewType>(() => - isGoogleBot ? +const LivePreview = lazy<typeof LivePreviewType>(() => + isBot ? Promise.resolve({ default: () => previewLoading }) : import( /* webpackChunkName: 'PreviewWithScope', webpackPrefetch: true */ './preview/LivePreview' diff --git a/website/src/components/Playground/isMobileOrBot.ts b/website/src/components/Playground/isMobileOrBot.ts deleted file mode 100644 index dedfedf8eb28..000000000000 --- a/website/src/components/Playground/isMobileOrBot.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** Shared user-agent patterns for bot/mobile detection. */ -export const BOT_UA_REGEX = /bot|googlebot|crawler|spider|robot|crawling/i; -export const MOBILE_OR_BOT_UA_REGEX = - /bot|googlebot|crawler|spider|robot|crawling|Mobile|Android|BlackBerry/i; - -/** True if crawler/bot (SSR-safe). Used for skipping Monaco/Preview load. */ -export const isGoogleBot = - typeof navigator === 'object' && - BOT_UA_REGEX.test(navigator?.userAgent ?? ''); - -/** Check if current browser is mobile or a bot - must be called client-side only */ -export function isMobileOrBot(): boolean { - return MOBILE_OR_BOT_UA_REGEX.test(navigator.userAgent); -} diff --git a/website/src/components/Playground/monaco-init.ts b/website/src/components/Playground/monaco-init.ts deleted file mode 100644 index 44b8d3b17919..000000000000 --- a/website/src/components/Playground/monaco-init.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { loader } from '@monaco-editor/react'; -import type * as Monaco from 'monaco-editor'; - -import { injectMonacoResourceHints } from './injectMonacoResourceHints'; -import { MOBILE_OR_BOT_UA_REGEX } from './isMobileOrBot'; -import { MONACO_CDN_VS } from './monacoPreloadManifest'; - -// Mobile and bots render PlaygroundLiveEditor instead, so skip downloading -// Monaco and the type bundles entirely. This module runs once per page load. -if ( - typeof window !== 'undefined' && - !MOBILE_OR_BOT_UA_REGEX.test(navigator.userAgent) -) { - const rhDeps = [ - 'rest', - 'rest/next', - 'react/next', - 'core/next', - 'core', - 'react', - 'endpoint', - 'normalizr', - 'graphql', - ] as const; - - const suggestionDependencies = [ - 'react', - '@data-client/rest', - '@data-client/react', - '@data-client/graphql', - 'bignumber.js', - ]; - - injectMonacoResourceHints(); - loader.config({ - paths: { - vs: MONACO_CDN_VS, - }, - }); - const monacoPromise = loader.init(); - - // Fetch type libs in parallel with Monaco CDN bootstrap (no webpackPreload — - // that raced high-priority against editor.main). Apply them only after init. - const typeLibsPromise = Promise.allSettled([ - import( - /* webpackChunkName: 'reactDTS' */ '!!raw-loader?esModule=false!./editor-types/react.d.ts' - ), - import( - /* webpackChunkName: 'bignumberDTS' */ '!!raw-loader?esModule=false!./editor-types/bignumber.d.ts' - ), - import( - /* webpackChunkName: 'numberflowDTS' */ '!!raw-loader?esModule=false!./editor-types/@number-flow/react.d.ts' - ), - import( - /* webpackChunkName: 'temporalDTS' */ '!!raw-loader?esModule=false!./editor-types/temporal.d.ts' - ), - import( - /* webpackChunkName: 'uuidDTS' */ '!!raw-loader?esModule=false!./editor-types/uuid.d.ts' - ), - import( - /* webpackChunkName: 'qsDTS' */ '!!raw-loader?esModule=false!./editor-types/qs.d.ts' - ), - import( - /* webpackChunkName: 'globalsDTS' */ '!!raw-loader?esModule=false!./editor-types/globals.d.ts' - ), - ...rhDeps.map( - dep => - import( - /* webpackChunkName: '[request]', webpackMode: "lazy-once" */ `!!raw-loader?esModule=false!./editor-types/@data-client/${dep}.d.ts` - ), - ), - ]); - - monacoPromise.then(async monaco => { - if (!monaco) return; - monaco.typescript.typescriptDefaults.setCompilerOptions({ - allowNonTsExtensions: true, - target: monaco.typescript.ScriptTarget.ES2017, - jsx: monaco.typescript.JsxEmit.ReactJSX, - strict: true, - strictNullChecks: true, - exactOptionalPropertyTypes: true, - lib: ['dom', 'esnext'], - module: monaco.typescript.ModuleKind.ESNext, - moduleResolution: monaco.typescript.ModuleResolutionKind.NodeJs, - allowSyntheticDefaultImports: true, - skipLibCheck: true, - skipDefaultLibCheck: true, - noImplicitAny: false, - }); - // TODO: load theme from docusaurus config so we eliminate DRY violation - // see https://microsoft.github.io/monaco-editor/playground.html for full options - monaco.editor.defineTheme('prism', { - base: 'vs-dark', - inherit: true, - rules: [ - { token: 'number', foreground: 'f78c6c' }, - { token: 'string', foreground: 'b6d986' }, - { - token: 'keyword', - fontStyle: 'italic', - foreground: '7da4f6', - }, - // type definitions variables like const X or class X - { token: 'type', foreground: 'ffcb6b' }, - { token: 'delimiter', foreground: 'C792EA' }, - - { token: 'tag', foreground: 'FF5590' }, - ], - colors: { - //'editor.background': '#292d3e', - 'editor.foreground': '#bfc7d5', - //'editor.lineHighlightBorder': '#33384d', - 'editor.inactiveSelectionBackground': '#484d5b', - }, - }); - // go to definition - monaco.editor.registerEditorOpener({ - openCodeEditor( - _sourceEditor: Monaco.editor.ICodeEditor, - resource: Monaco.Uri, - selectionOrPosition?: Monaco.IRange | Monaco.IPosition, - ) { - if (resource.path.startsWith('/')) { - // alternatively set model directly in the editor if you have your own tab/navigation implementation\ - const model = monaco.editor.getModel(resource); - const destinationEditor = monaco.editor - .getEditors() - .find( - (editor: Monaco.editor.ICodeEditor) => - editor.getModel() === model, - ); - if (!destinationEditor) return false; - // focus event is handled by editor to show that tab - destinationEditor.focus(); - requestIdleCallback(() => { - if (monaco.Range.isIRange(selectionOrPosition)) { - destinationEditor.revealRangeInCenterIfOutsideViewport( - selectionOrPosition, - ); - destinationEditor.setSelection(selectionOrPosition); - } else { - if (selectionOrPosition) { - destinationEditor.revealPositionInCenterIfOutsideViewport( - selectionOrPosition, - ); - destinationEditor.setPosition(selectionOrPosition); - } - } - destinationEditor.focus(); - }); - - return true; - } - return false; - }, - }); - // autocomplete imports - monaco.languages.registerCompletionItemProvider('typescript', { - // These characters should trigger our `provideCompletionItems` function - triggerCharacters: ["'", '"', '.', '/'], - // Function which returns a list of autocompletion ietems. If we return an empty array, there won't be any autocompletion. - provideCompletionItems: ( - model: Monaco.editor.ITextModel, - position: Monaco.Position, - ) => { - // Get all the text content before the cursor - const textUntilPosition = model.getValueInRange({ - startLineNumber: 1, - startColumn: 1, - endLineNumber: position.lineNumber, - endColumn: position.column, - }); - // Get the current word - const word = model.getWordUntilPosition(position); - const range = { - startLineNumber: position.lineNumber, - endLineNumber: position.lineNumber, - startColumn: word.startColumn, - endColumn: word.endColumn, - }; - // Match things like `from "` and `require("` - if ( - /(([\s|\n]+from\s+)|(\brequire\b\s*\())["|'][^'^"]*$/.test( - textUntilPosition, - ) - ) { - // It's probably a `import` statement or `require` call - if ( - textUntilPosition.endsWith('.') || - textUntilPosition.endsWith('/') - ) { - const completions = monaco.editor - .getModels() - .map( - (editorModel: Monaco.editor.ITextModel) => editorModel.uri.path, - ) - .map((path: string) => { - const candidateId = /\/\d+\//g.exec(path)?.[0] ?? ''; - const file = path.substring(candidateId.length - 1); - return { - // Show the full file path for label - label: file, - // Don't keep extension for JS files - insertText: file.replace(/\.tsx?$/, ''), - kind: monaco.languages.CompletionItemKind.Module, - range, - }; - }); - if (!completions.length) return { suggestions: [] }; - return { suggestions: completions }; - } else { - // User is trying to import a dependency - return { - suggestions: suggestionDependencies.map(name => ({ - label: name, - //detail: suggestionDependencies[name], - kind: monaco.languages.CompletionItemKind.Module, - insertText: name, - range, - })), - }; - } - } - return { suggestions: [] }; - }, - }); - - const settles = await typeLibsPromise; - const [ - react, - bignumber, - numberFlow, - temporal, - uuid, - qs, - globals, - ...rhLibs - ] = settles.map(result => - result.status === 'fulfilled' ? result.value.default : '', - ); - - monaco.typescript.typescriptDefaults.addExtraLib( - `declare module "react/jsx-runtime" { - import './'; - }`, - 'file:///node_modules/@types/react/jsx-runtime.d.ts', - ); - monaco.typescript.typescriptDefaults.addExtraLib( - `import * as React from 'react' - - declare global { - namespace JSX { - interface IntrinsicElements { - strike: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>; - } - } - }`, - 'file:///node_modules/@types/react/more.d.ts', - ); - monaco.typescript.typescriptDefaults.addExtraLib( - `declare function render(component:JSX.Element):void; - declare function uuid(): string; - declare function CurrentTime(props: {}):JSX.Element; - declare function CancelButton(props: { onClick?: () => void }):JSX.Element; - declare function Avatar(props: { src: string }):JSX.Element; - declare function Formatted({ downColor, formatter, formatterFn, timeout, transition, transitionLength, upColor, value, stylePrefix, }: NumberProps):JSX.Element - declare function ResetableErrorBoundary(props: { children: React.ReactNode }):JSX.Element; - declare function TextInput(props:Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> & { label?: React.ReactNode; loading?: boolean; size?: 'large' | 'medium' | 'small'; }):JSX.Element; - declare function TextArea(props:InputHTMLAttributes<HTMLTextAreaElement> & { label?: React.ReactNode;}):JSX.Element; - declare function SearchIcon():JSX.Element; - declare function Loading():JSX.Element; - declare function randomFloatInRange(min: number, max: number, decimals?: number): number; - declare interface NumberProps { - /** - * Color value when the component flashes 'down'. - */ - downColor?: string; - /** - * One of the built in formatters. - */ - formatter?: 'currency' | 'percentage' | 'number'; - /** - * Pass your own formatter function. - */ - formatterFn?: Formatter; - /** - * Prefix for the CSS selectors in the DOM. - */ - stylePrefix?: string; - /** - * Amount of time the flashed state is visible for, in milliseconds. - */ - timeout?: number; - /** - * Custom CSS transition property. - */ - transition?: string; - /** - * Transition length, in milliseconds. - */ - transitionLength?: number; - /** - * Color value when the component flashes 'up'. - */ - upColor?: string; - /** - * Value to display. The only required prop. - */ - value: number; - }`, - ); - monaco.typescript.typescriptDefaults.addExtraLib( - `declare module "react" { ${react} }`, - 'file:///node_modules/@types/react/index.d.ts', - ); - monaco.typescript.typescriptDefaults.addExtraLib( - `declare module "bignumber.js" { ${bignumber} }`, - 'file:///node_modules/bignumber.js/index.d.ts', - ); - monaco.typescript.typescriptDefaults.addExtraLib( - `declare module "@number-flow/react" { ${numberFlow} };`, - 'file:///node_modules/@number-flow/react/index.d.ts', - ); - monaco.typescript.typescriptDefaults.addExtraLib( - `declare module "temporal-polyfill" { ${temporal} }`, - 'file:///node_modules/temporal-polyfill/index.d.ts', - ); - monaco.typescript.typescriptDefaults.addExtraLib( - `declare module "uuid" { ${uuid} }`, - 'file:///node_modules/@types/uuid/index.d.ts', - ); - monaco.typescript.typescriptDefaults.addExtraLib( - `declare module "qs" { ${qs} }`, - 'file:///node_modules/@types/qs/index.d.ts', - ); - monaco.typescript.typescriptDefaults.addExtraLib( - `declare globals { ${react} }`, - ); - monaco.typescript.typescriptDefaults.addExtraLib( - `declare globals { export { default as NumberFlow } from '@number-flow/react'; }`, - ); - monaco.typescript.typescriptDefaults.addExtraLib( - `declare globals { export { Temporal, DateTimeFormat } from 'temporal-polyfill'; }`, - ); - - rhLibs.forEach((lib, i) => { - const dep = rhDeps[i]; - monaco.typescript.typescriptDefaults.addExtraLib( - `declare module "@data-client/${dep}" { ${lib} }`, - `file:///node_modules/@data-client/${dep}/index.d.ts`, - ); - }); - - monaco.typescript.typescriptDefaults.addExtraLib( - `declare globals { ${globals} }`, - ); - - monaco.typescript.typescriptDefaults.setEagerModelSync(true); - return monaco; - }); -} diff --git a/website/src/components/Playground/monaco/__tests__/highlightSelections.test.ts b/website/src/components/Playground/monaco/__tests__/highlightSelections.test.ts new file mode 100644 index 000000000000..43eb292bf540 --- /dev/null +++ b/website/src/components/Playground/monaco/__tests__/highlightSelections.test.ts @@ -0,0 +1,28 @@ +/// <reference types="jest" /> + +import { highlightSelections } from '../highlightSelections'; + +const lines = (start: number, end: number) => ({ + selectionStartLineNumber: start, + selectionStartColumn: 0, + positionLineNumber: end, + positionColumn: 0, +}); + +describe('highlightSelections', () => { + test('selects nothing without lines', () => { + expect(highlightSelections([])).toEqual([]); + }); + + test('selects a single line through the start of the next', () => { + expect(highlightSelections([2])).toEqual([lines(2, 3)]); + }); + + test('merges consecutive lines and splits gaps', () => { + expect(highlightSelections([1, 2, 3, 5, 7, 8])).toEqual([ + lines(1, 4), + lines(5, 6), + lines(7, 9), + ]); + }); +}); diff --git a/website/src/components/Playground/monaco/__tests__/modelPath.test.ts b/website/src/components/Playground/monaco/__tests__/modelPath.test.ts new file mode 100644 index 000000000000..372a63565b31 --- /dev/null +++ b/website/src/components/Playground/monaco/__tests__/modelPath.test.ts @@ -0,0 +1,47 @@ +/// <reference types="jest" /> + +import { renderHook } from '@testing-library/react'; + +import { + modelPath, + siblingFilePaths, + stripModelId, + useModelId, +} from '../modelPath'; + +describe('model paths', () => { + test('round-trips a namespaced file path', () => { + expect(modelPath('123', 'src/api.ts')).toBe('/123/src/api.ts'); + expect(stripModelId(modelPath('123', 'src/api.ts'))).toBe('/src/api.ts'); + }); + + test('leaves paths without an id segment unchanged', () => { + expect(stripModelId('/node_modules/react/index.d.ts')).toBe( + '/node_modules/react/index.d.ts', + ); + }); + + test('siblingFilePaths lists only other files in the same editor', () => { + expect( + siblingFilePaths('/1/index.tsx', [ + '/1/index.tsx', + '/1/api.ts', + '/2/other.ts', + '/12/near-miss.ts', + '/node_modules/react/index.d.ts', + ]), + ).toEqual(['/api.ts']); + expect(siblingFilePaths('/plain.ts', ['/plain.ts', '/other.ts'])).toEqual( + [], + ); + }); + + test('useModelId is numeric and stable across renders', () => { + const { result, rerender } = renderHook(() => useModelId()); + const first = result.current; + expect(first).toMatch(/^\d+$/); + expect(stripModelId(modelPath(first, 'a.ts'))).toBe('/a.ts'); + rerender(); + expect(result.current).toBe(first); + }); +}); diff --git a/website/src/components/Playground/monaco/highlightSelections.ts b/website/src/components/Playground/monaco/highlightSelections.ts new file mode 100644 index 000000000000..c68f00d2a876 --- /dev/null +++ b/website/src/components/Playground/monaco/highlightSelections.ts @@ -0,0 +1,34 @@ +import type { ISelection } from 'monaco-editor'; + +/** + * Converts sorted highlighted line numbers (parsed from a code-fence range + * like `{1-3,5}`) into Monaco selections, merging consecutive lines into one + * whole-line selection each. + */ +export function highlightSelections(lines: readonly number[]): ISelection[] { + if (!lines.length) return []; + + let selectionStartLineNumber = lines[0]; + let positionLineNumber = selectionStartLineNumber; + const selections: ISelection[] = []; + const pushSelection = () => + selections.push({ + selectionStartLineNumber, + selectionStartColumn: 0, + positionLineNumber, + positionColumn: 0, + }); + + lines.forEach(lineNumber => { + // more of same selection + if (lineNumber === positionLineNumber) { + positionLineNumber++; + } else { + pushSelection(); + selectionStartLineNumber = lineNumber; + positionLineNumber = lineNumber + 1; + } + }); + pushSelection(); + return selections; +} diff --git a/website/src/components/Playground/extensionToMonacoLanguage.tsx b/website/src/components/Playground/monaco/language.ts similarity index 100% rename from website/src/components/Playground/extensionToMonacoLanguage.tsx rename to website/src/components/Playground/monaco/language.ts diff --git a/website/src/components/Playground/monaco/modelPath.ts b/website/src/components/Playground/monaco/modelPath.ts new file mode 100644 index 000000000000..44a8a80752ec --- /dev/null +++ b/website/src/components/Playground/monaco/modelPath.ts @@ -0,0 +1,45 @@ +import { useMemo } from 'react'; + +/** + * Monaco models are shared page-wide, so each editor surface namespaces its + * files as `/<numeric id>/<file path>`. Go-to-definition and import + * completions (./navigation.ts) rely on this shape. + */ +const MODEL_ID_ROOT = /^\/\d+\//; + +export function modelPath(editorId: string, filePath: string) { + return `/${editorId}/${filePath}`; +} + +/** `/123/src/api.ts` → `/src/api.ts` (unchanged when there is no id segment) */ +export function stripModelId(path: string) { + return path.replace(MODEL_ID_ROOT, '/'); +} + +/** `/123/src/api.ts` → `/123/` (undefined when there is no id segment) */ +function modelIdSegment(path: string) { + return MODEL_ID_ROOT.exec(path)?.[0]; +} + +/** + * Other files in the same editor surface as `currentPath`, id stripped: + * import completions should not offer files from other playgrounds on the page. + */ +export function siblingFilePaths( + currentPath: string, + modelPaths: readonly string[], +): string[] { + const id = modelIdSegment(currentPath); + if (!id) return []; + return modelPaths + .filter(path => path !== currentPath && modelIdSegment(path) === id) + .map(stripModelId); +} + +/** Stable per-mount id. Also runs during SSR, but the id only reaches Monaco (inside BrowserOnly), never markup, so randomness cannot cause hydration mismatches. */ +export function useModelId() { + return useMemo( + () => Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(), + [], + ); +} diff --git a/website/src/components/Playground/monaco/navigation.ts b/website/src/components/Playground/monaco/navigation.ts new file mode 100644 index 000000000000..782059b3f516 --- /dev/null +++ b/website/src/components/Playground/monaco/navigation.ts @@ -0,0 +1,119 @@ +import type * as Monaco from 'monaco-editor'; + +import { siblingFilePaths } from './modelPath'; + +/** Packages offered when completing a bare `from '…'` specifier. */ +const SUGGESTED_DEPENDENCIES = [ + 'react', + '@data-client/rest', + '@data-client/react', + '@data-client/graphql', + 'bignumber.js', +]; + +/** + * Cross-tab go-to-definition: focus the editor that owns the target model. + * Focusing it makes EditorSurface reveal that tab (see its onFocus handlers). + */ +export function registerEditorOpener(monaco: typeof Monaco) { + monaco.editor.registerEditorOpener({ + openCodeEditor( + _sourceEditor: Monaco.editor.ICodeEditor, + resource: Monaco.Uri, + selectionOrPosition?: Monaco.IRange | Monaco.IPosition, + ) { + if (!resource.path.startsWith('/')) return false; + + const model = monaco.editor.getModel(resource); + const destinationEditor = monaco.editor + .getEditors() + .find( + (editor: Monaco.editor.ICodeEditor) => editor.getModel() === model, + ); + if (!destinationEditor) return false; + // focus event is handled by editor to show that tab + destinationEditor.focus(); + requestIdleCallback(() => { + if (monaco.Range.isIRange(selectionOrPosition)) { + destinationEditor.revealRangeInCenterIfOutsideViewport( + selectionOrPosition, + ); + destinationEditor.setSelection(selectionOrPosition); + } else if (selectionOrPosition) { + destinationEditor.revealPositionInCenterIfOutsideViewport( + selectionOrPosition, + ); + destinationEditor.setPosition(selectionOrPosition); + } + destinationEditor.focus(); + }); + + return true; + }, + }); +} + +/** + * Completes import specifiers: relative paths list the other files in the + * same playground, bare specifiers list SUGGESTED_DEPENDENCIES. + */ +export function registerImportCompletions(monaco: typeof Monaco) { + monaco.languages.registerCompletionItemProvider('typescript', { + triggerCharacters: ["'", '"', '.', '/'], + provideCompletionItems: ( + model: Monaco.editor.ITextModel, + position: Monaco.Position, + ) => { + const textUntilPosition = model.getValueInRange({ + startLineNumber: 1, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column, + }); + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + // Match things like `from "` and `require("` + if ( + !/(([\s|\n]+from\s+)|(\brequire\b\s*\())["|'][^'^"]*$/.test( + textUntilPosition, + ) + ) { + return { suggestions: [] }; + } + + if (textUntilPosition.endsWith('.') || textUntilPosition.endsWith('/')) { + return { + suggestions: siblingFilePaths( + model.uri.path, + monaco.editor + .getModels() + .map( + (editorModel: Monaco.editor.ITextModel) => editorModel.uri.path, + ), + ).map((file: string) => ({ + // Show the full file path for label + label: file, + // Don't keep extension for JS files + insertText: file.replace(/\.tsx?$/, ''), + kind: monaco.languages.CompletionItemKind.Module, + range, + })), + }; + } + // User is trying to import a dependency + return { + suggestions: SUGGESTED_DEPENDENCIES.map(name => ({ + label: name, + kind: monaco.languages.CompletionItemKind.Module, + insertText: name, + range, + })), + }; + }, + }); +} diff --git a/website/src/components/Playground/monacoOptions.tsx b/website/src/components/Playground/monaco/options.ts similarity index 100% rename from website/src/components/Playground/monacoOptions.tsx rename to website/src/components/Playground/monaco/options.ts diff --git a/website/src/components/Playground/monacoPreloadManifest.ts b/website/src/components/Playground/monaco/preloadManifest.ts similarity index 100% rename from website/src/components/Playground/monacoPreloadManifest.ts rename to website/src/components/Playground/monaco/preloadManifest.ts diff --git a/website/src/components/Playground/injectMonacoResourceHints.ts b/website/src/components/Playground/monaco/resourceHints.ts similarity index 91% rename from website/src/components/Playground/injectMonacoResourceHints.ts rename to website/src/components/Playground/monaco/resourceHints.ts index 363a3c8b2167..c393d725f689 100644 --- a/website/src/components/Playground/injectMonacoResourceHints.ts +++ b/website/src/components/Playground/monaco/resourceHints.ts @@ -1,4 +1,4 @@ -import { MONACO_CDN_VS, monacoPreloadManifest } from './monacoPreloadManifest'; +import { MONACO_CDN_VS, monacoPreloadManifest } from './preloadManifest'; const RESOURCE_HINT_ATTRIBUTE = 'data-monaco-resource-hint'; diff --git a/website/src/components/Playground/monaco/setup.ts b/website/src/components/Playground/monaco/setup.ts new file mode 100644 index 000000000000..f63d68c3d422 --- /dev/null +++ b/website/src/components/Playground/monaco/setup.ts @@ -0,0 +1,55 @@ +/** + * Side-effect module: import it from any component that renders Monaco. + * + * It runs once per page load, as soon as the importing chunk evaluates (before + * any editor mounts), so Monaco's CDN files and our type libs download while + * the page hydrates. Mobile and bots never render Monaco (see ../userAgent.ts), + * so they skip all of these downloads. + */ +import { loader } from '@monaco-editor/react'; + +import { isMobileOrBot } from '../userAgent'; +import { registerEditorOpener, registerImportCompletions } from './navigation'; +import { MONACO_CDN_VS } from './preloadManifest'; +import { injectMonacoResourceHints } from './resourceHints'; +import { definePrismTheme } from './theme'; +import { addTypeLibs, fetchTypeLibs } from './typeLibs'; + +if (typeof window !== 'undefined' && !isMobileOrBot()) { + injectMonacoResourceHints(); + loader.config({ + paths: { + vs: MONACO_CDN_VS, + }, + }); + const monacoPromise = loader.init(); + + // Fetch type libs in parallel with Monaco CDN bootstrap (no webpackPreload — + // that raced high-priority against editor.main). Apply them only after init. + const typeLibsPromise = fetchTypeLibs(); + + monacoPromise.then(async monaco => { + if (!monaco) return; + monaco.typescript.typescriptDefaults.setCompilerOptions({ + allowNonTsExtensions: true, + target: monaco.typescript.ScriptTarget.ES2017, + jsx: monaco.typescript.JsxEmit.ReactJSX, + strict: true, + strictNullChecks: true, + exactOptionalPropertyTypes: true, + lib: ['dom', 'esnext'], + module: monaco.typescript.ModuleKind.ESNext, + moduleResolution: monaco.typescript.ModuleResolutionKind.NodeJs, + allowSyntheticDefaultImports: true, + skipLibCheck: true, + skipDefaultLibCheck: true, + noImplicitAny: false, + }); + definePrismTheme(monaco); + registerEditorOpener(monaco); + registerImportCompletions(monaco); + + addTypeLibs(monaco, await typeLibsPromise); + monaco.typescript.typescriptDefaults.setEagerModelSync(true); + }); +} diff --git a/website/src/components/Playground/monaco/theme.ts b/website/src/components/Playground/monaco/theme.ts new file mode 100644 index 000000000000..5c8a2549d62f --- /dev/null +++ b/website/src/components/Playground/monaco/theme.ts @@ -0,0 +1,33 @@ +import type * as Monaco from 'monaco-editor'; + +/** Monaco theme name matching the site's Prism (palenight) code blocks. */ +export const MONACO_THEME = 'prism'; + +// TODO: load theme from docusaurus config so we eliminate DRY violation +// see https://microsoft.github.io/monaco-editor/playground.html for full options +export function definePrismTheme(monaco: typeof Monaco) { + monaco.editor.defineTheme(MONACO_THEME, { + base: 'vs-dark', + inherit: true, + rules: [ + { token: 'number', foreground: 'f78c6c' }, + { token: 'string', foreground: 'b6d986' }, + { + token: 'keyword', + fontStyle: 'italic', + foreground: '7da4f6', + }, + // type definitions variables like const X or class X + { token: 'type', foreground: 'ffcb6b' }, + { token: 'delimiter', foreground: 'C792EA' }, + + { token: 'tag', foreground: 'FF5590' }, + ], + colors: { + //'editor.background': '#292d3e', + 'editor.foreground': '#bfc7d5', + //'editor.lineHighlightBorder': '#33384d', + 'editor.inactiveSelectionBackground': '#484d5b', + }, + }); +} diff --git a/website/src/components/Playground/monaco/typeLibs.ts b/website/src/components/Playground/monaco/typeLibs.ts new file mode 100644 index 000000000000..e70ba8a9afff --- /dev/null +++ b/website/src/components/Playground/monaco/typeLibs.ts @@ -0,0 +1,232 @@ +import type * as Monaco from 'monaco-editor'; + +/** + * Type definitions for editor intellisense. Each is a separate webpack chunk + * (raw text) fetched in parallel with Monaco's CDN bootstrap; a failed fetch + * degrades to an empty lib rather than breaking the editor. + */ + +type RawModule = Promise<{ default: string }>; + +/** + * `declare module "<name>"` libs, mounted at file:///node_modules/<dir>/index.d.ts. + * `global` libs are also declared globally (they are in the preview scope). + */ +const MODULE_LIBS: readonly [ + name: string, + dir: string, + load: () => RawModule, + global?: true, +][] = [ + [ + 'react', + '@types/react', + () => + import( + /* webpackChunkName: 'reactDTS' */ '!!raw-loader?esModule=false!../editor-types/react.d.ts' + ), + true, + ], + [ + 'bignumber.js', + 'bignumber.js', + () => + import( + /* webpackChunkName: 'bignumberDTS' */ '!!raw-loader?esModule=false!../editor-types/bignumber.d.ts' + ), + ], + [ + '@number-flow/react', + '@number-flow/react', + () => + import( + /* webpackChunkName: 'numberflowDTS' */ '!!raw-loader?esModule=false!../editor-types/@number-flow/react.d.ts' + ), + ], + [ + 'temporal-polyfill', + 'temporal-polyfill', + () => + import( + /* webpackChunkName: 'temporalDTS' */ '!!raw-loader?esModule=false!../editor-types/temporal.d.ts' + ), + ], + [ + 'uuid', + '@types/uuid', + () => + import( + /* webpackChunkName: 'uuidDTS' */ '!!raw-loader?esModule=false!../editor-types/uuid.d.ts' + ), + ], + [ + 'qs', + '@types/qs', + () => + import( + /* webpackChunkName: 'qsDTS' */ '!!raw-loader?esModule=false!../editor-types/qs.d.ts' + ), + ], +]; + +/** `@data-client/<entry>` libs; one lazy-once chunk for the whole directory */ +const DATA_CLIENT_ENTRIES = [ + 'rest', + 'rest/next', + 'react/next', + 'core/next', + 'core', + 'react', + 'endpoint', + 'normalizr', + 'graphql', +] as const; + +/** Playground-only globals (editor-types/globals.d.ts) */ +const loadGlobals = (): RawModule => + import( + /* webpackChunkName: 'globalsDTS' */ '!!raw-loader?esModule=false!../editor-types/globals.d.ts' + ); + +/** Ambient declarations for names the preview injects via react-live scope (see ../preview/scope.ts). */ +const PREVIEW_SCOPE_DECLARATIONS = `declare function render(component:JSX.Element):void; + declare function uuid(): string; + declare function CurrentTime(props: {}):JSX.Element; + declare function CancelButton(props: { onClick?: () => void }):JSX.Element; + declare function Avatar(props: { src: string }):JSX.Element; + declare function Formatted({ downColor, formatter, formatterFn, timeout, transition, transitionLength, upColor, value, stylePrefix, }: NumberProps):JSX.Element + declare function ResetableErrorBoundary(props: { children: React.ReactNode }):JSX.Element; + declare function TextInput(props:Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> & { label?: React.ReactNode; loading?: boolean; size?: 'large' | 'medium' | 'small'; }):JSX.Element; + declare function TextArea(props:InputHTMLAttributes<HTMLTextAreaElement> & { label?: React.ReactNode;}):JSX.Element; + declare function SearchIcon():JSX.Element; + declare function Loading():JSX.Element; + declare function randomFloatInRange(min: number, max: number, decimals?: number): number; + declare interface NumberProps { + /** + * Color value when the component flashes 'down'. + */ + downColor?: string; + /** + * One of the built in formatters. + */ + formatter?: 'currency' | 'percentage' | 'number'; + /** + * Pass your own formatter function. + */ + formatterFn?: Formatter; + /** + * Prefix for the CSS selectors in the DOM. + */ + stylePrefix?: string; + /** + * Amount of time the flashed state is visible for, in milliseconds. + */ + timeout?: number; + /** + * Custom CSS transition property. + */ + transition?: string; + /** + * Transition length, in milliseconds. + */ + transitionLength?: number; + /** + * Color value when the component flashes 'up'. + */ + upColor?: string; + /** + * Value to display. The only required prop. + */ + value: number; + }`; + +export interface TypeLibs { + modules: string[]; + dataClient: string[]; + globals: string; +} + +/** Start every type-lib download now; resolves once all have settled. */ +export function fetchTypeLibs(): Promise<TypeLibs> { + const settled = (promises: RawModule[]) => + Promise.allSettled(promises).then(results => + results.map(result => + result.status === 'fulfilled' ? result.value.default : '', + ), + ); + + return Promise.all([ + settled(MODULE_LIBS.map(([, , load]) => load())), + loadGlobals().then( + ({ default: lib }) => lib, + () => '', + ), + settled( + DATA_CLIENT_ENTRIES.map( + entry => + import( + /* webpackChunkName: '[request]', webpackMode: "lazy-once" */ `!!raw-loader?esModule=false!../editor-types/@data-client/${entry}.d.ts` + ), + ), + ), + ]).then(([modules, globals, dataClient]) => ({ + modules, + dataClient, + globals, + })); +} + +export function addTypeLibs( + monaco: typeof Monaco, + { modules, dataClient, globals }: TypeLibs, +) { + const { typescriptDefaults } = monaco.typescript; + + typescriptDefaults.addExtraLib( + `declare module "react/jsx-runtime" { + import './'; + }`, + 'file:///node_modules/@types/react/jsx-runtime.d.ts', + ); + typescriptDefaults.addExtraLib( + `import * as React from 'react' + + declare global { + namespace JSX { + interface IntrinsicElements { + strike: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>; + } + } + }`, + 'file:///node_modules/@types/react/more.d.ts', + ); + typescriptDefaults.addExtraLib(PREVIEW_SCOPE_DECLARATIONS); + + MODULE_LIBS.forEach(([name, dir], i) => { + typescriptDefaults.addExtraLib( + `declare module "${name}" { ${modules[i]} }`, + `file:///node_modules/${dir}/index.d.ts`, + ); + }); + + // React, NumberFlow and Temporal are also globals in the preview scope + MODULE_LIBS.forEach(([, , , global], i) => { + if (global) + typescriptDefaults.addExtraLib(`declare globals { ${modules[i]} }`); + }); + typescriptDefaults.addExtraLib( + `declare globals { export { default as NumberFlow } from '@number-flow/react'; }`, + ); + typescriptDefaults.addExtraLib( + `declare globals { export { Temporal, DateTimeFormat } from 'temporal-polyfill'; }`, + ); + + DATA_CLIENT_ENTRIES.forEach((entry, i) => { + typescriptDefaults.addExtraLib( + `declare module "@data-client/${entry}" { ${dataClient[i]} }`, + `file:///node_modules/@data-client/${entry}/index.d.ts`, + ); + }); + + typescriptDefaults.addExtraLib(`declare globals { ${globals} }`); +} diff --git a/website/src/components/Playground/useAutoHeight.tsx b/website/src/components/Playground/monaco/useAutoHeight.ts similarity index 100% rename from website/src/components/Playground/useAutoHeight.tsx rename to website/src/components/Playground/monaco/useAutoHeight.ts diff --git a/website/src/components/Playground/FixturePreview.tsx b/website/src/components/Playground/preview/FixturePreview.tsx similarity index 95% rename from website/src/components/Playground/FixturePreview.tsx rename to website/src/components/Playground/preview/FixturePreview.tsx index 2b82e937eea6..e900665a3173 100644 --- a/website/src/components/Playground/FixturePreview.tsx +++ b/website/src/components/Playground/preview/FixturePreview.tsx @@ -2,8 +2,8 @@ import BrowserOnly from '@docusaurus/BrowserOnly'; import CodeBlock from '@theme/CodeBlock'; import { memo, type ReactElement } from 'react'; -import styles from './styles.module.css'; -import type { FixtureOrInterceptor } from './types'; +import styles from '../styles.module.css'; +import type { FixtureOrInterceptor } from '../types'; function FixturePreview({ fixtures }: { fixtures: FixtureOrInterceptor[] }) { return ( diff --git a/website/src/components/Playground/preview/LivePreview.tsx b/website/src/components/Playground/preview/LivePreview.tsx index bee9dcac8a80..7183ad4f19fc 100644 --- a/website/src/components/Playground/preview/LivePreview.tsx +++ b/website/src/components/Playground/preview/LivePreview.tsx @@ -1,10 +1,10 @@ import { LiveProvider } from 'react-live'; +import Preview from './Preview'; +import PreviewWrapper from './PreviewWrapper'; import { previewScope } from './scope'; +import transformCode from './transformCode'; import { usePlaygroundConsoleDemotion } from './usePlaygroundConsoleDemotion'; -import Preview from '../Preview'; -import PreviewWrapper from '../PreviewWrapper'; -import transformCode from '../transformCode'; import type { PreviewProps } from '../types'; export interface LivePreviewProps<T> extends PreviewProps<T> { diff --git a/website/src/components/Playground/Preview.tsx b/website/src/components/Playground/preview/Preview.tsx similarity index 91% rename from website/src/components/Playground/Preview.tsx rename to website/src/components/Playground/preview/Preview.tsx index 21a255592d9d..2718fcf25173 100644 --- a/website/src/components/Playground/Preview.tsx +++ b/website/src/components/Playground/preview/Preview.tsx @@ -9,11 +9,11 @@ import { useScrollPositionBlocker } from '@docusaurus/theme-common/internal'; import clsx from 'clsx'; import React, { memo, useCallback, useMemo, lazy } from 'react'; -import Boundary from './Boundary'; +import Boundary from '../Boundary'; import StoreInspector from './StoreInspector'; -import styles from './styles.module.css'; -import type { PreviewProps } from './types'; -import { useTabStorage } from '../../utils/tabStorage'; +import { useTabStorage } from '../../../utils/tabStorage'; +import styles from '../styles.module.css'; +import type { PreviewProps } from '../types'; function Preview<T>({ groupId, diff --git a/website/src/components/Playground/PreviewBlock.tsx b/website/src/components/Playground/preview/PreviewBlock.tsx similarity index 69% rename from website/src/components/Playground/PreviewBlock.tsx rename to website/src/components/Playground/preview/PreviewBlock.tsx index 1478dc8f47d8..ddc8417681a6 100644 --- a/website/src/components/Playground/PreviewBlock.tsx +++ b/website/src/components/Playground/preview/PreviewBlock.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { LiveError, LivePreview } from 'react-live'; -import Boundary from './Boundary'; -import { Loading } from './DesignSystem/Loading'; -import styles from './styles.module.css'; +import Boundary from '../Boundary'; +import { Loading } from '../DesignSystem/Loading'; +import styles from '../styles.module.css'; export default function PreviewBlock() { return ( diff --git a/website/src/components/Playground/PreviewWrapper.tsx b/website/src/components/Playground/preview/PreviewWrapper.tsx similarity index 87% rename from website/src/components/Playground/PreviewWrapper.tsx rename to website/src/components/Playground/preview/PreviewWrapper.tsx index db5d2dc189ab..bc3586bf49ec 100644 --- a/website/src/components/Playground/PreviewWrapper.tsx +++ b/website/src/components/Playground/preview/PreviewWrapper.tsx @@ -1,8 +1,8 @@ import Translate from '@docusaurus/Translate'; import React from 'react'; -import Header from './Header'; -import styles from './styles.module.css'; +import Header from '../Header'; +import styles from '../styles.module.css'; export default function PreviewWrapper({ children }: Props) { return ( diff --git a/website/src/components/Playground/StoreInspector.tsx b/website/src/components/Playground/preview/StoreInspector.tsx similarity index 90% rename from website/src/components/Playground/StoreInspector.tsx rename to website/src/components/Playground/preview/StoreInspector.tsx index 113fb8fb92e6..81d861af8e89 100644 --- a/website/src/components/Playground/StoreInspector.tsx +++ b/website/src/components/Playground/preview/StoreInspector.tsx @@ -2,7 +2,7 @@ import { StateContext } from '@data-client/react'; import clsx from 'clsx'; import React, { useContext, memo, useMemo } from 'react'; -import styles from './styles.module.css'; +import styles from '../styles.module.css'; import Tree from './Tree'; function StoreInspector({ @@ -24,7 +24,7 @@ function StoreInspector({ } export default memo(StoreInspector); -/** Toggle row shared with the preview loading fallback in ./index.tsx */ +/** Toggle row; also rendered (inert) by the preview loading fallback in ../index.tsx */ export function StoreToggle({ onClick, open = true, diff --git a/website/src/components/Playground/Tree.tsx b/website/src/components/Playground/preview/Tree.tsx similarity index 100% rename from website/src/components/Playground/Tree.tsx rename to website/src/components/Playground/preview/Tree.tsx diff --git a/website/src/components/Playground/__tests__/transformCode.test.ts b/website/src/components/Playground/preview/__tests__/transformCode.test.ts similarity index 100% rename from website/src/components/Playground/__tests__/transformCode.test.ts rename to website/src/components/Playground/preview/__tests__/transformCode.test.ts diff --git a/website/src/components/Playground/preview/scope.ts b/website/src/components/Playground/preview/scope.ts index 84a4a8aa6120..2bdc881be02f 100644 --- a/website/src/components/Playground/preview/scope.ts +++ b/website/src/components/Playground/preview/scope.ts @@ -8,7 +8,7 @@ import { v4 as uuid } from 'uuid'; import ResetableErrorBoundary from '../../ResettableErrorBoundary'; import * as designSystem from '../DesignSystem'; -import { Temporal, Intl, DateTimeFormat } from '../temporal'; +import { Temporal, Intl, DateTimeFormat } from './temporal'; function randomFloatInRange(min: number, max: number, decimals: number) { return parseFloat((Math.random() * (max - min) + min).toFixed(decimals)); diff --git a/website/src/components/Playground/temporal.ts b/website/src/components/Playground/preview/temporal.ts similarity index 100% rename from website/src/components/Playground/temporal.ts rename to website/src/components/Playground/preview/temporal.ts diff --git a/website/src/components/Playground/transformCode.ts b/website/src/components/Playground/preview/transformCode.ts similarity index 100% rename from website/src/components/Playground/transformCode.ts rename to website/src/components/Playground/preview/transformCode.ts diff --git a/website/src/components/Playground/userAgent.ts b/website/src/components/Playground/userAgent.ts new file mode 100644 index 000000000000..1bb59fca5c29 --- /dev/null +++ b/website/src/components/Playground/userAgent.ts @@ -0,0 +1,30 @@ +/** + * User-agent gates for the playground's heavy client code. + * + * - Bots/crawlers: never load Monaco or the live preview; the SSG markup + * (static code + empty preview frame) is what gets indexed. + * - Mobile: no Monaco (poor touch UX and download cost); an editable + * react-live editor is used instead, but the live preview still runs. + */ +const BOT_UA = /bot|googlebot|crawler|spider|robot|crawling/i; +const MOBILE_UA = /Mobile|Android|BlackBerry/i; + +function userAgent(): string { + return typeof navigator === 'object' ? (navigator?.userAgent ?? '') : ''; +} + +/** + * True for crawlers. Evaluated once at module load and false during SSR, so + * only use it outside render (e.g. in a `lazy()` factory or an effect) to + * avoid hydration mismatches. + */ +export const isBot = BOT_UA.test(userAgent()); + +/** + * True for mobile browsers and crawlers. Returns false during SSR, so only + * call it client-side after hydration (e.g. inside `<BrowserOnly>`). + */ +export function isMobileOrBot(): boolean { + const ua = userAgent(); + return BOT_UA.test(ua) || MOBILE_UA.test(ua); +} diff --git a/website/src/components/StackBlitz.tsx b/website/src/components/StackBlitz.tsx index 369f35c8e2f6..e457e34c3112 100644 --- a/website/src/components/StackBlitz.tsx +++ b/website/src/components/StackBlitz.tsx @@ -1,7 +1,7 @@ import Link from '@docusaurus/Link'; import type { ReactElement } from 'react'; -import { isGoogleBot } from './Playground/isMobileOrBot'; +import { isBot } from './Playground/userAgent'; import { useHasIntersected } from './useHasIntersected'; export default function StackBlitz({ @@ -49,7 +49,7 @@ export default function StackBlitz({ const [frameRef, hasIntersected] = useHasIntersected<HTMLIFrameElement>(); let embedElement: ReactElement; - if (!hasIntersected || isGoogleBot) { + if (!hasIntersected || isBot) { embedElement = ( <iframe width={width} height={height} ref={frameRef}></iframe> );