From 566f9775bc306f1a41806a29eb22182700765974 Mon Sep 17 00:00:00 2001 From: Jack Pettit <57518417+JackDevAU@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:32:15 +1000 Subject: [PATCH 1/6] refactor: fix and refactor the way we handle resizing (#6403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: - https://github.com/tinacms/tinacms/issues/6349 ## How to test 1. Run a TinaCMS site with the admin iframe preview (e.g. `examples/next/kitchen-sink`, then open `/admin`). 2. Drag the sidebar's resize handle and **release the mouse while the cursor is over the preview iframe**. 3. Move the cursor over the preview and scroll — it should scroll right away. _(Before this fix, scrolling/clicking the preview stayed dead until you clicked the sidebar again.)_ 4. Repeat the resize a few times and confirm clicking elements in the preview also works immediately after each drag. 5. Open the sidebar in fullscreen and confirm the resize handle is hidden. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: kulesy Co-authored-by: Claude Opus 5 Co-authored-by: Matt Wicks [SSW] Co-authored-by: Eli Kent [SSW] <69125238+kulesy@users.noreply.github.com> --- .changeset/great-buses-roll.md | 9 ++ .../src/toolkit/components/resize-overlay.tsx | 20 ++++ .../src/toolkit/components/tina-ui.tsx | 16 +-- .../components/resize-handle.test.tsx | 113 ++++++++++++++++++ .../components/resize-handle.tsx | 70 +++++------ .../react-sidebar/components/sidebar.tsx | 8 +- 6 files changed, 187 insertions(+), 49 deletions(-) create mode 100644 .changeset/great-buses-roll.md create mode 100644 packages/tinacms/src/toolkit/components/resize-overlay.tsx create mode 100644 packages/tinacms/src/toolkit/react-sidebar/components/resize-handle.test.tsx diff --git a/.changeset/great-buses-roll.md b/.changeset/great-buses-roll.md new file mode 100644 index 0000000000..d04d8b2600 --- /dev/null +++ b/.changeset/great-buses-roll.md @@ -0,0 +1,9 @@ +--- +"tinacms": patch +--- + +Fix the preview iframe going unresponsive after resizing the sidebar. + +Dragging the resize handle used to disable pointer events on the entire app so the drag would survive the cursor crossing into the preview. Releasing the drag over the preview left it dead to clicks and scrolling until you clicked the sidebar again. The handle now uses pointer capture, which keeps the drag targeting the handle without touching the rest of the page, so the preview stays interactive throughout. + +Also corrects the handle's fullscreen guard, which read a `fullscreen` key the sidebar context has never provided and so never fired. No behaviour change today, since nothing currently puts the sidebar into the fullscreen display state. diff --git a/packages/tinacms/src/toolkit/components/resize-overlay.tsx b/packages/tinacms/src/toolkit/components/resize-overlay.tsx new file mode 100644 index 0000000000..8a17897f2e --- /dev/null +++ b/packages/tinacms/src/toolkit/components/resize-overlay.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; + +interface ResizeOverlayProps { + isResizing: boolean; +} + +/** + * Pointer capture on the handle is what keeps the drag alive over the iframe. + * This only wins hit-testing, so the resize cursor holds across the whole window. + */ +export const ResizeOverlay: React.FC = ({ isResizing }) => { + if (!isResizing) return null; + + return ( +
+ ); +}; diff --git a/packages/tinacms/src/toolkit/components/tina-ui.tsx b/packages/tinacms/src/toolkit/components/tina-ui.tsx index e09aa4bad7..d3c2ee4354 100644 --- a/packages/tinacms/src/toolkit/components/tina-ui.tsx +++ b/packages/tinacms/src/toolkit/components/tina-ui.tsx @@ -4,14 +4,15 @@ */ -import * as React from 'react'; +import { Alerts } from '@toolkit/react-alerts'; import { ModalProvider } from '@toolkit/react-modals'; -import { SidebarProvider, SidebarPosition } from '@toolkit/react-sidebar'; +import { SidebarPosition, SidebarProvider } from '@toolkit/react-sidebar'; +import * as React from 'react'; import { useCMS } from '../react-tinacms/use-cms'; -import { Alerts } from '@toolkit/react-alerts'; -import { MediaManager } from './media'; import { ActiveFieldIndicator } from './active-field-indicator'; +import { MediaManager } from './media'; import { MutationSignalProvider } from './mutation-signal'; +import { ResizeOverlay } from './resize-overlay'; export interface TinaUIProps { position?: SidebarPosition; @@ -37,10 +38,9 @@ export const TinaUI: React.FC = ({ children, position }) => { /> )} - {/* Dragging across the iframe causes mouse events to stop propagating so there's a laggy feeling without this */} -
- {children} -
+ {/* Overlay captures mouse events during resize without affecting iframe focus */} + + {children} ); diff --git a/packages/tinacms/src/toolkit/react-sidebar/components/resize-handle.test.tsx b/packages/tinacms/src/toolkit/react-sidebar/components/resize-handle.test.tsx new file mode 100644 index 0000000000..a4d5ffa183 --- /dev/null +++ b/packages/tinacms/src/toolkit/react-sidebar/components/resize-handle.test.tsx @@ -0,0 +1,113 @@ +import { cleanup, fireEvent, render } from '@testing-library/react'; +import * as React from 'react'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { ResizeOverlay } from '../../components/resize-overlay'; +import { ResizeHandle } from './resize-handle'; +import { SidebarContext } from './sidebar'; + +beforeAll(() => { + Element.prototype.setPointerCapture = vi.fn(); + Element.prototype.releasePointerCapture = vi.fn(); + Element.prototype.hasPointerCapture = vi.fn(() => false); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +/* Mirrors how TinaUI wires the handle and the overlay to one resizing flag */ +const Harness = ({ displayState = 'open' }: { displayState?: string }) => { + const [resizingSidebar, setResizingSidebar] = React.useState(false); + const [sidebarWidth, setSidebarWidth] = React.useState(440); + + return ( + + + + + ); +}; + +describe('ResizeHandle', () => { + it('captures the pointer and shows the overlay on a primary-button press', () => { + const app = render(); + + expect(app.queryByTestId('resize-overlay')).toBeNull(); + + fireEvent.pointerDown(app.getByTestId('resize-handle'), { + button: 0, + pointerId: 1, + }); + + expect(Element.prototype.setPointerCapture).toHaveBeenCalledWith(1); + expect(app.queryByTestId('resize-overlay')).not.toBeNull(); + }); + + it('ignores non-primary buttons, so a right-click cannot strand the overlay', () => { + const app = render(); + + fireEvent.pointerDown(app.getByTestId('resize-handle'), { + button: 2, + pointerId: 1, + }); + + expect(Element.prototype.setPointerCapture).not.toHaveBeenCalled(); + expect(app.queryByTestId('resize-overlay')).toBeNull(); + }); + + it('ends the resize when pointer capture is lost', () => { + const app = render(); + const handle = app.getByTestId('resize-handle'); + + fireEvent.pointerDown(handle, { button: 0, pointerId: 1 }); + expect(app.queryByTestId('resize-overlay')).not.toBeNull(); + + fireEvent.lostPointerCapture(handle, { pointerId: 1 }); + + expect(app.queryByTestId('resize-overlay')).toBeNull(); + }); + + /* onLostPointerCapture never reaches React if the handle unmounts mid-drag */ + it('ends the resize on a window pointerup even if capture is never lost', () => { + const app = render(); + + fireEvent.pointerDown(app.getByTestId('resize-handle'), { + button: 0, + pointerId: 1, + }); + expect(app.queryByTestId('resize-overlay')).not.toBeNull(); + + fireEvent.pointerUp(window, { pointerId: 1 }); + + expect(app.queryByTestId('resize-overlay')).toBeNull(); + }); + + it('ends the resize when the pointer is cancelled', () => { + const app = render(); + + fireEvent.pointerDown(app.getByTestId('resize-handle'), { + button: 0, + pointerId: 1, + }); + expect(app.queryByTestId('resize-overlay')).not.toBeNull(); + + fireEvent.pointerCancel(window, { pointerId: 1 }); + + expect(app.queryByTestId('resize-overlay')).toBeNull(); + }); + + it('is not rendered while the sidebar is fullscreen', () => { + const app = render(); + + expect(app.queryByTestId('resize-handle')).toBeNull(); + }); +}); diff --git a/packages/tinacms/src/toolkit/react-sidebar/components/resize-handle.tsx b/packages/tinacms/src/toolkit/react-sidebar/components/resize-handle.tsx index 82c1f5ddee..12c6afff94 100644 --- a/packages/tinacms/src/toolkit/react-sidebar/components/resize-handle.tsx +++ b/packages/tinacms/src/toolkit/react-sidebar/components/resize-handle.tsx @@ -1,66 +1,58 @@ import * as React from 'react'; -import { SidebarContext, minSidebarWidth } from './sidebar'; +import { SidebarContext, minSidebarWidth, sidebarEdgeGap } from './sidebar'; export const ResizeHandle = () => { - const { - resizingSidebar, - setResizingSidebar, - fullscreen, - setSidebarWidth, - displayState, - } = React.useContext(SidebarContext); + const { resizingSidebar, setResizingSidebar, setSidebarWidth, displayState } = + React.useContext(SidebarContext); - React.useEffect(() => { - const handleMouseUp = () => setResizingSidebar(false); - - window.addEventListener('mouseup', handleMouseUp); + const startResizing = (e: React.PointerEvent) => { + if (e.button !== 0) return; + // Capture so move/up keep targeting the handle once the cursor is over the iframe. + e.currentTarget.setPointerCapture(e.pointerId); + setResizingSidebar(true); + }; - return () => { - window.removeEventListener('mouseup', handleMouseUp); - }; - }, []); + const stopResizing = () => setResizingSidebar(false); React.useEffect(() => { - const handleMouseMove = (e: any) => { - setSidebarWidth((sidebarWidth) => { - /* Get value from CSS if sidebarWidth isn't set yet */ - const newWidth = sidebarWidth + e.movementX; - const maxWidth = window.innerWidth - 8; + if (!resizingSidebar) return; - if (newWidth < minSidebarWidth) { - return minSidebarWidth; - } else if (newWidth > maxWidth) { - return maxWidth; - } else { - return newWidth; - } + const handlePointerMove = (e: PointerEvent) => { + setSidebarWidth((sidebarWidth: number) => { + const newWidth = sidebarWidth + e.movementX; + const maxWidth = window.innerWidth - sidebarEdgeGap; + return Math.max(minSidebarWidth, Math.min(maxWidth, newWidth)); }); }; - if (resizingSidebar) { - window.addEventListener('mousemove', handleMouseMove); - document.body.classList.add('select-none'); - } + window.addEventListener('pointermove', handlePointerMove); + // Backstop for the cases onLostPointerCapture misses, e.g. the handle + // unmounting mid-drag, which would strand the overlay over the whole app. + window.addEventListener('pointerup', stopResizing); + window.addEventListener('pointercancel', stopResizing); + document.body.classList.add('select-none'); return () => { - window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', stopResizing); + window.removeEventListener('pointercancel', stopResizing); document.body.classList.remove('select-none'); }; - }, [resizingSidebar]); - - const handleresizingSidebar = () => setResizingSidebar(true); + }, [resizingSidebar, setSidebarWidth]); - if (fullscreen) { + if (displayState === 'fullscreen') { return null; } return (
diff --git a/packages/tinacms/src/toolkit/react-sidebar/components/sidebar.tsx b/packages/tinacms/src/toolkit/react-sidebar/components/sidebar.tsx index 711b8f2c43..640789157e 100644 --- a/packages/tinacms/src/toolkit/react-sidebar/components/sidebar.tsx +++ b/packages/tinacms/src/toolkit/react-sidebar/components/sidebar.tsx @@ -28,6 +28,8 @@ import { FormsView } from './sidebar-body'; export const SidebarContext = React.createContext(null); export const minPreviewWidth = 440; export const minSidebarWidth = 360; +/* Sliver of the window kept clear of the sidebar at its max width */ +export const sidebarEdgeGap = 8; const LOCALSTATEKEY = 'tina.sidebarState'; const LOCALWIDTHKEY = 'tina.sidebarWidth'; @@ -537,8 +539,10 @@ const SidebarWrapper = ({ children }) => { style={{ width: displayState === 'fullscreen' ? '100vw' : `${sidebarWidth}px`, maxWidth: - displayState === 'fullscreen' ? '100vw' : 'calc(100vw - 8px)', - minWidth: '360px', + displayState === 'fullscreen' + ? '100vw' + : `calc(100vw - ${sidebarEdgeGap}px)`, + minWidth: `${minSidebarWidth}px`, }} > {children} From 976a93ca16f85343566b0a3e36618e7330b8d94f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:21:29 +1000 Subject: [PATCH 2/6] chore(deps): bump esbuild from 0.18.20 to 0.28.1 (#7076) Co-authored-by: Josh Berman Co-authored-by: Claude Fable 5 Signed-off-by: dependabot[bot] --- .changeset/spicy-donuts-refuse.md | 6 ++++++ pnpm-lock.yaml | 14 +++++++------- pnpm-workspace.yaml | 2 +- 3 files changed, 14 insertions(+), 8 deletions(-) create mode 100644 .changeset/spicy-donuts-refuse.md diff --git a/.changeset/spicy-donuts-refuse.md b/.changeset/spicy-donuts-refuse.md new file mode 100644 index 0000000000..37075bd6f0 --- /dev/null +++ b/.changeset/spicy-donuts-refuse.md @@ -0,0 +1,6 @@ +--- +'@tinacms/cli': patch +'@tinacms/scripts': patch +--- + +Bump `esbuild` to 0.28.1, picking up upstream security fixes (GHSA-g7r4-m6w7-qqqr, GHSA-gv7w-rqvm-qjhr) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ce7a5f356..7e1d0437c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -382,8 +382,8 @@ catalogs: specifier: ^16.4.7 version: 16.6.1 esbuild: - specifier: ^0.25.0 - version: 0.25.12 + specifier: ^0.28.1 + version: 0.28.1 estree-util-is-identifier-name: specifier: 2.1.0 version: 2.1.0 @@ -1419,7 +1419,7 @@ importers: version: 16.6.1 esbuild: specifier: 'catalog:' - version: 0.25.12 + version: 0.28.1 fs-extra: specifier: 'catalog:' version: 11.3.2 @@ -1549,7 +1549,7 @@ importers: version: 2.1.0 ts-jest: specifier: 'catalog:' - version: 29.4.6(@babel/core@7.29.7)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.7)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)))(typescript@5.9.3) packages/@tinacms/datalayer: dependencies: @@ -1910,7 +1910,7 @@ importers: version: 7.2.0 esbuild: specifier: 'catalog:' - version: 0.25.12 + version: 0.28.1 fs-extra: specifier: 'catalog:' version: 11.3.2 @@ -33713,7 +33713,7 @@ snapshots: babel-jest: 30.2.0(@babel/core@7.29.7) jest-util: 30.2.0 - ts-jest@29.4.6(@babel/core@7.29.7)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)))(typescript@5.9.3): + ts-jest@29.4.6(@babel/core@7.29.7)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -33731,7 +33731,7 @@ snapshots: '@jest/transform': 30.2.0 '@jest/types': 30.2.0 babel-jest: 29.7.0(@babel/core@7.29.7) - esbuild: 0.25.12 + esbuild: 0.28.1 jest-util: 30.2.0 ts-jest@29.4.6(@babel/core@7.29.7)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.7))(jest-util@30.2.0)(jest@29.7.0(@types/node@25.1.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.1.0)(typescript@5.9.3)))(typescript@5.9.3): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6f5c6a69fe..1ed6c260d1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -164,7 +164,7 @@ catalog: cross-spawn: ^7.0.6 crypto-js: ^4.2.0 dotenv: ^16.4.7 - esbuild: ^0.25.0 + esbuild: ^0.28.1 estree-util-is-identifier-name: 2.1.0 fast-glob: ^3.3.3 final-form: 4.20.10 From b8c3c13e9fb64d9fe539f265ba517429e37cbe42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:38:36 +1000 Subject: [PATCH 3/6] chore(deps): bump tar from 7.4.0 to 7.5.21 (#7320) Bumps [tar](https://github.com/isaacs/node-tar) from 7.4.0 to 7.5.21.
Changelog

Sourced from tar's changelog.

Changelog

7.5

  • Added zstd compression support.
  • Consistent TOCTOU behavior in sync t.list
  • Only read from ustar block if not specified in Pax
  • Fix sync tar.list when file size reduces while reading
  • Sanitize absolute linkpaths properly
  • Prevent writing hardlink entries to the archive ahead of their file target

7.4

  • Deprecate onentry in favor of onReadEntry for clarity.

7.3

  • Add onWriteEntry option

7.2

  • DRY the command definitions into a single makeCommand method, and update the type signatures to more appropriately infer the return type from the options and arguments provided.

7.1

  • Update minipass to v7.1.0
  • Update the type definitions of write() and end() methods on Unpack and Parser classes to be compatible with the NodeJS.WritableStream type in the latest versions of @types/node.

7.0

  • Drop support for node <18
  • Rewrite in TypeScript, provide ESM and CommonJS hybrid interface
  • Add tree-shake friendly exports, like import('tar/create') and import('tar/read-entry') to get individual functions or classes.
  • Add chmod option that defaults to false, and deprecate noChmod. That is, reverse the default option regarding explicitly setting file system modes to match tar entry settings.
  • Add processUmask option to avoid having to call process.umask() when chmod: true (or noChmod: false) is set.

... (truncated)

Commits
Maintainer changes

This version was pushed to npm by isaacs, a new releaser for tar since your current version.

Install script changes

This version adds prepare script that runs during installation. Review the package contents before updating.


--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Eli Kent [SSW] <69125238+kulesy@users.noreply.github.com> Co-authored-by: kulesy Co-authored-by: Claude Opus 5 --- .changeset/bump-tar-7-5-21.md | 5 +++++ pnpm-lock.yaml | 20 +++++++++----------- pnpm-workspace.yaml | 2 +- 3 files changed, 15 insertions(+), 12 deletions(-) create mode 100644 .changeset/bump-tar-7-5-21.md diff --git a/.changeset/bump-tar-7-5-21.md b/.changeset/bump-tar-7-5-21.md new file mode 100644 index 0000000000..44c23b89c8 --- /dev/null +++ b/.changeset/bump-tar-7-5-21.md @@ -0,0 +1,5 @@ +--- +'create-tina-app': patch +--- + +Bump `tar` to 7.5.21 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e1d0437c8..cce12d0836 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -601,8 +601,8 @@ catalogs: specifier: 4.0.3 version: 4.0.3 tar: - specifier: 7.4.0 - version: 7.4.0 + specifier: 7.5.21 + version: 7.5.21 ts-jest: specifier: ^29.2.5 version: 29.4.6 @@ -2036,7 +2036,7 @@ importers: version: 5.27.14 tar: specifier: 'catalog:' - version: 7.4.0 + version: 7.5.21 devDependencies: '@tinacms/scripts': specifier: workspace:* @@ -15119,15 +15119,14 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} - tar@7.4.0: - resolution: {integrity: sha512-XQs0S8fuAkQWuqhDeCdMlJXDX80D7EOVLDPVFkna9yQfzS+PHKgfxcei0jf6/+QAWcjqrnC8uM3fSAnrQl+XYg==} - engines: {node: '>=18'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - tar@7.5.13: resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} engines: {node: '>=18'} + tar@7.5.21: + resolution: {integrity: sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==} + engines: {node: '>=18'} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -33518,16 +33517,15 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - tar@7.4.0: + tar@7.5.13: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 minipass: 7.1.2 minizlib: 3.1.0 - mkdirp: 3.0.1 yallist: 5.0.0 - tar@7.5.13: + tar@7.5.21: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1ed6c260d1..5d4ca412ae 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -245,7 +245,7 @@ catalog: stopword: ^3.1.4 stringify-entities: 4.0.3 tailwindcss-animate: ^1.0.7 - tar: 7.4.0 + tar: 7.5.21 ts-jest: ^29.2.5 ts-node: ^10.9.2 tsc-alias: ^1.8.10 From 4b7d9b9f116f7f649aae1a573c838a663f97d99d Mon Sep 17 00:00:00 2001 From: "Brook Jeynes [SSW]" Date: Mon, 17 Aug 2026 15:55:16 +1000 Subject: [PATCH 4/6] feat: add tina-markdown web component (#7392) There's no way to render markdown content when not using a framework. This adds a `tina-markdown` web component which can be passed tina's markdown AST. ```js const postsResponse = await client.queries.postConnection(); const posts = postsResponse.data.postConnection.edges.map((post) => { return { title: post.node.title, body: post.node.body, }; }); const postsContainer = document.getElementById("posts"); for (const post of posts) { const tinaMarkdown = document.createElement("tina-markdown"); const markdownAst = JSON.stringify(post.body); tinaMarkdown.setAttribute("content", markdownAst) postsContainer.appendChild(tinaMarkdown); } ``` See [examples/js/markdown-rendering/index.html](https://github.com/tinacms/tinacms/blob/bj/20260803/feat/tina-markdown-web-component/examples/js/markdown-rendering/index.html) for a more detailed example --------- Assisted-by: OpenCode:big-pickle Signed-off-by: brookjeynes-ssw Co-authored-by: Matt Wicks [SSW] Co-authored-by: Claude Opus 5 (1M context) --- .changeset/ninety-deserts-turn.md | 9 + .changeset/sanitise-tina-markdown-raw-html.md | 7 + .../web-components/kitchen-sink/.gitignore | 1 + .../kitchen-sink/admin/.gitignore | 2 + .../content/posts/My-first-blog.mdx | 9 + .../posts/Reject-frameworks-accept-JS.mdx | 5 + .../content/posts/Supported-Markdown.mdx | 62 +++ .../kitchen-sink/example-image.jpg | Bin 0 -> 127514 bytes .../web-components/kitchen-sink/index.html | 166 +++++++ .../web-components/kitchen-sink/package.json | 16 + .../kitchen-sink/post-preview.js | 36 ++ .../web-components/kitchen-sink/style.css | 55 +++ .../kitchen-sink/tina/.gitignore | 1 + .../kitchen-sink/tina/config.js | 57 +++ .../kitchen-sink/tina/tina-lock.json | 1 + .../astro/src/__tests__/TinaMarkdown.test.ts | 68 +++ .../src/__tests__/fixtures/RawHtml.astro | 9 + packages/@tinacms/cli/package.json | 1 - .../cli/src/next/codegen/index.test.ts | 2 +- .../@tinacms/cli/src/next/codegen/index.ts | 15 +- .../src/next/commands/dev-command/index.ts | 2 +- .../next/commands/dev-command/server/index.ts | 3 +- .../@tinacms/cli/src/next/vite/plugins.ts | 1 - packages/@tinacms/cli/tsconfig.json | 4 +- packages/@tinacms/scripts/src/index.ts | 14 + packages/@tinacms/web-components/README.md | 201 +++++++++ packages/@tinacms/web-components/package.json | 40 ++ .../web-components/src/tina-markdown.js | 179 ++++++++ .../web-components/src/tina-markdown.test.ts | 421 ++++++++++++++++++ .../web-components/src/visual-editing.js | 136 ++++++ .../@tinacms/web-components/tsconfig.json | 18 + .../@tinacms/web-components/vitest.config.ts | 10 + packages/tinacms/package.json | 1 - packages/tinacms/src/rich-text/index.test.tsx | 79 ++++ .../src/unifiedClient/asyncLock.test.ts | 76 ++++ .../tinacms/src/unifiedClient/asyncLock.ts | 48 ++ packages/tinacms/src/unifiedClient/index.ts | 4 +- pnpm-lock.yaml | 72 +-- pnpm-workspace.yaml | 2 +- tests/build-verification.test.ts | 18 + 40 files changed, 1803 insertions(+), 48 deletions(-) create mode 100644 .changeset/ninety-deserts-turn.md create mode 100644 .changeset/sanitise-tina-markdown-raw-html.md create mode 100644 examples/web-components/kitchen-sink/.gitignore create mode 100644 examples/web-components/kitchen-sink/admin/.gitignore create mode 100644 examples/web-components/kitchen-sink/content/posts/My-first-blog.mdx create mode 100644 examples/web-components/kitchen-sink/content/posts/Reject-frameworks-accept-JS.mdx create mode 100644 examples/web-components/kitchen-sink/content/posts/Supported-Markdown.mdx create mode 100644 examples/web-components/kitchen-sink/example-image.jpg create mode 100644 examples/web-components/kitchen-sink/index.html create mode 100644 examples/web-components/kitchen-sink/package.json create mode 100644 examples/web-components/kitchen-sink/post-preview.js create mode 100644 examples/web-components/kitchen-sink/style.css create mode 100644 examples/web-components/kitchen-sink/tina/.gitignore create mode 100644 examples/web-components/kitchen-sink/tina/config.js create mode 100644 examples/web-components/kitchen-sink/tina/tina-lock.json create mode 100644 packages/@tinacms/astro/src/__tests__/fixtures/RawHtml.astro create mode 100644 packages/@tinacms/web-components/README.md create mode 100644 packages/@tinacms/web-components/package.json create mode 100644 packages/@tinacms/web-components/src/tina-markdown.js create mode 100644 packages/@tinacms/web-components/src/tina-markdown.test.ts create mode 100644 packages/@tinacms/web-components/src/visual-editing.js create mode 100644 packages/@tinacms/web-components/tsconfig.json create mode 100644 packages/@tinacms/web-components/vitest.config.ts create mode 100644 packages/tinacms/src/unifiedClient/asyncLock.test.ts create mode 100644 packages/tinacms/src/unifiedClient/asyncLock.ts diff --git a/.changeset/ninety-deserts-turn.md b/.changeset/ninety-deserts-turn.md new file mode 100644 index 0000000000..9c42b36010 --- /dev/null +++ b/.changeset/ninety-deserts-turn.md @@ -0,0 +1,9 @@ +--- +"tinacms": minor +"@tinacms/cli": minor +"@tinacms/schema-tools": minor +"@tinacms/web-components": minor +--- + +feat: add tina-markdown web component +feat: add visual-editing library for web components diff --git a/.changeset/sanitise-tina-markdown-raw-html.md b/.changeset/sanitise-tina-markdown-raw-html.md new file mode 100644 index 0000000000..df7f900483 --- /dev/null +++ b/.changeset/sanitise-tina-markdown-raw-html.md @@ -0,0 +1,7 @@ +--- +"@tinacms/web-components": minor +--- + +Sanitise `html` / `html_inline` nodes in `tina-markdown` and add a +`TinaMarkdown.components` map for per-node-type renderers, matching the +`components` prop on the React and Astro renderers. diff --git a/examples/web-components/kitchen-sink/.gitignore b/examples/web-components/kitchen-sink/.gitignore new file mode 100644 index 0000000000..3c3629e647 --- /dev/null +++ b/examples/web-components/kitchen-sink/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/examples/web-components/kitchen-sink/admin/.gitignore b/examples/web-components/kitchen-sink/admin/.gitignore new file mode 100644 index 0000000000..c6a8f8ff67 --- /dev/null +++ b/examples/web-components/kitchen-sink/admin/.gitignore @@ -0,0 +1,2 @@ +index.html +assets/ \ No newline at end of file diff --git a/examples/web-components/kitchen-sink/content/posts/My-first-blog.mdx b/examples/web-components/kitchen-sink/content/posts/My-first-blog.mdx new file mode 100644 index 0000000000..288c4075d8 --- /dev/null +++ b/examples/web-components/kitchen-sink/content/posts/My-first-blog.mdx @@ -0,0 +1,9 @@ +--- +title: My first blog +--- + +Woah! what do you know. This is my *first blog post*. + +Just think of all the **things** I could write… + +~~I wonder if…~~ diff --git a/examples/web-components/kitchen-sink/content/posts/Reject-frameworks-accept-JS.mdx b/examples/web-components/kitchen-sink/content/posts/Reject-frameworks-accept-JS.mdx new file mode 100644 index 0000000000..ed6f6cc5bc --- /dev/null +++ b/examples/web-components/kitchen-sink/content/posts/Reject-frameworks-accept-JS.mdx @@ -0,0 +1,5 @@ +--- +title: 'Reject frameworks, accept JS' +--- + +You read the title, JS is the **goat**. diff --git a/examples/web-components/kitchen-sink/content/posts/Supported-Markdown.mdx b/examples/web-components/kitchen-sink/content/posts/Supported-Markdown.mdx new file mode 100644 index 0000000000..83bfcb03c6 --- /dev/null +++ b/examples/web-components/kitchen-sink/content/posts/Supported-Markdown.mdx @@ -0,0 +1,62 @@ +--- +title: Supported Markdown +--- + +# Heading 1 + +## Heading 2 + +### Heading 3 + +#### Heading 4 + +##### Heading 5 + +###### Heading 6 + +This is a paragraph! + +1. ordered list item 1 +2. ordered list item 2 + +* unordered list item 1 +* unordered list item 2 + +> This is a block quote! + +![](/example-image.jpg) + +[This is a block link](https://tina.io) + +This is an [inline link](https://tina.io) !! + +```javascript +function helloWorld(name) { + console.log("hello", name) +} +``` + +*** + +this is + +a manual + +line break + +| You | Me | Us | +| ----- | -------- | --------------------- | +| Okay | Handsome | A match | +| Funny | Dumb | Yet another match \<3 | + +
+
+

This is raw html

+
+
+ +this is `inline` code block + + + This is a custom component! + diff --git a/examples/web-components/kitchen-sink/example-image.jpg b/examples/web-components/kitchen-sink/example-image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..94ed1c48789e09d0b8d898288a006329cfacd891 GIT binary patch literal 127514 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 + + + + + + + + + + + + +

My Tina site

+ +
+

Blog posts

+
    +
+
+ + + + + diff --git a/examples/web-components/kitchen-sink/package.json b/examples/web-components/kitchen-sink/package.json new file mode 100644 index 0000000000..58b59f7a63 --- /dev/null +++ b/examples/web-components/kitchen-sink/package.json @@ -0,0 +1,16 @@ +{ + "name": "@examples/web-components-kitchen-sink", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "tinacms dev -c \"python3 -m http.server\"", + "build": "tinacms build" + }, + "dependencies": { + "@tinacms/web-components": "workspace:^" + }, + "devDependencies": { + "@tinacms/cli": "workspace:*", + "tinacms": "workspace:*" + } +} diff --git a/examples/web-components/kitchen-sink/post-preview.js b/examples/web-components/kitchen-sink/post-preview.js new file mode 100644 index 0000000000..29564c145e --- /dev/null +++ b/examples/web-components/kitchen-sink/post-preview.js @@ -0,0 +1,36 @@ +const postPreviewTemplate = document.createElement('template'); +postPreviewTemplate.id = 'post-preview'; +postPreviewTemplate.innerHTML = ` + + +
+

+ +

+

+ +

+
+
+`; + +class PostPreviewComponent extends HTMLElement { + constructor() { + super(); + const shadowRoot = this.attachShadow({ mode: 'closed' }); + + shadowRoot.appendChild(postPreviewTemplate.content.cloneNode(true)); + } +} + +customElements.define('post-preview', PostPreviewComponent); diff --git a/examples/web-components/kitchen-sink/style.css b/examples/web-components/kitchen-sink/style.css new file mode 100644 index 0000000000..f1f27f5f2f --- /dev/null +++ b/examples/web-components/kitchen-sink/style.css @@ -0,0 +1,55 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +:root { + --text: #1a1a1a; + --off-white: #f7f7f7; + --light-gray: #e8e8e8; + --dark-gray: #c0c0c0; +} + +table { + border-collapse: separate; + border: 2px solid var(--dark-gray); + text-align: left; +} + +thead { + background-color: var(--off-white); + + th { + border: 1px solid var(--dark-gray); + } +} + +th, +td { + padding: 0.1rem 0.5rem; +} + +tbody > tr:nth-of-type(even) { + background-color: var(--light-gray); +} + +blockquote { + background-color: var(--light-gray); + border-left: 5px solid var(--dark-gray); + margin: 1.5em 10px; + padding: 0.5em 10px; +} + +pre:has(> code) { + padding: 0.5em 1rem; + border: 1px solid var(--text); + + > code { + font-family: "monospace"; + } +} + +img { + width: 100%; +} diff --git a/examples/web-components/kitchen-sink/tina/.gitignore b/examples/web-components/kitchen-sink/tina/.gitignore new file mode 100644 index 0000000000..764f8b2387 --- /dev/null +++ b/examples/web-components/kitchen-sink/tina/.gitignore @@ -0,0 +1 @@ +__generated__ diff --git a/examples/web-components/kitchen-sink/tina/config.js b/examples/web-components/kitchen-sink/tina/config.js new file mode 100644 index 0000000000..48a002290c --- /dev/null +++ b/examples/web-components/kitchen-sink/tina/config.js @@ -0,0 +1,57 @@ +import { defineConfig } from 'tinacms'; + +const branch = 'main'; + +export default defineConfig({ + branch, + clientId: process.env.NEXT_PUBLIC_TINA_CLIENT_ID, + token: process.env.TINA_TOKEN, + build: { + outputFolder: 'admin', + publicFolder: './', + }, + media: { + tina: { + mediaRoot: '', + publicFolder: './', + }, + }, + schema: { + collections: [ + { + name: 'post', + label: 'Posts', + path: 'content/posts', + ui: { + router: () => '/', + }, + format: 'mdx', + fields: [ + { + type: 'string', + name: 'title', + label: 'Title', + isTitle: true, + required: true, + }, + { + type: 'rich-text', + name: 'body', + label: 'Body', + isBody: true, + templates: [ + { + name: 'PostPreview', + label: 'Post Preview', + fields: [ + { type: 'string', name: 'title' }, + { type: 'rich-text', name: 'children' }, + ], + }, + ], + }, + ], + }, + ], + }, +}); diff --git a/examples/web-components/kitchen-sink/tina/tina-lock.json b/examples/web-components/kitchen-sink/tina/tina-lock.json new file mode 100644 index 0000000000..3d534250ed --- /dev/null +++ b/examples/web-components/kitchen-sink/tina/tina-lock.json @@ -0,0 +1 @@ +{"schema":{"version":{"fullVersion":"2.4.9","major":"2","minor":"4","patch":"9"},"meta":{"flags":["experimentalData"]},"collections":[{"name":"post","label":"Posts","path":"content/posts","ui":{},"format":"mdx","fields":[{"type":"string","name":"title","label":"Title","isTitle":true,"required":true,"namespace":["post","title"],"searchable":true,"uid":false},{"type":"rich-text","name":"body","label":"Body","isBody":true,"templates":[{"name":"PostPreview","label":"Post Preview","fields":[{"type":"string","name":"title","namespace":["post","body","PostPreview","title"],"searchable":true,"uid":false},{"type":"rich-text","name":"children","namespace":["post","body","PostPreview","children"],"searchable":true,"parser":{"type":"mdx"},"uid":false}],"namespace":["post","body","PostPreview"]}],"namespace":["post","body"],"searchable":true,"parser":{"type":"mdx"},"uid":false}],"namespace":["post"]}],"config":{"media":{"tina":{"publicFolder":"./","mediaRoot":""}}}},"lookup":{"DocumentConnection":{"type":"DocumentConnection","resolveType":"multiCollectionDocumentList","collections":["post"]},"Node":{"type":"Node","resolveType":"nodeDocument"},"DocumentNode":{"type":"DocumentNode","resolveType":"multiCollectionDocument","createDocument":"create","updateDocument":"update"},"Post":{"type":"Post","resolveType":"collectionDocument","collection":"post","createPost":"create","updatePost":"update"},"PostConnection":{"type":"PostConnection","resolveType":"collectionDocumentList","collection":"post"}},"graphql":{"kind":"Document","definitions":[{"kind":"ScalarTypeDefinition","name":{"kind":"Name","value":"Reference"},"description":{"kind":"StringValue","value":"References another document, used as a foreign key"},"directives":[]},{"kind":"ScalarTypeDefinition","name":{"kind":"Name","value":"JSON"},"description":{"kind":"StringValue","value":""},"directives":[]},{"kind":"ObjectTypeDefinition","interfaces":[],"directives":[],"name":{"kind":"Name","value":"SystemInfo"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"filename"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"title"},"arguments":[],"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"basename"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"hasReferences"},"arguments":[],"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"breadcrumbs"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"excludeExtension"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}],"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"path"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"relativePath"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"extension"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"template"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"collection"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Collection"}}}}]},{"kind":"ObjectTypeDefinition","interfaces":[],"directives":[],"name":{"kind":"Name","value":"Folder"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"name"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"path"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}]},{"kind":"ObjectTypeDefinition","interfaces":[],"directives":[],"name":{"kind":"Name","value":"PageInfo"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"hasPreviousPage"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"hasNextPage"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"startCursor"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"endCursor"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}]},{"kind":"InterfaceTypeDefinition","description":{"kind":"StringValue","value":""},"name":{"kind":"Name","value":"Node"},"interfaces":[],"directives":[],"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"id"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}]},{"kind":"InterfaceTypeDefinition","description":{"kind":"StringValue","value":""},"name":{"kind":"Name","value":"Document"},"interfaces":[],"directives":[],"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"id"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"_sys"},"arguments":[],"type":{"kind":"NamedType","name":{"kind":"Name","value":"SystemInfo"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"_values"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}]},{"kind":"InterfaceTypeDefinition","description":{"kind":"StringValue","value":"A relay-compliant pagination connection"},"name":{"kind":"Name","value":"Connection"},"interfaces":[],"directives":[],"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"totalCount"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"pageInfo"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PageInfo"}}}}]},{"kind":"ObjectTypeDefinition","interfaces":[],"directives":[],"name":{"kind":"Name","value":"Query"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"getOptimizedQuery"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"queryString"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"collection"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"collection"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Collection"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"collections"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Collection"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"node"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Node"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"document"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"collection"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"relativePath"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DocumentNode"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"post"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"relativePath"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Post"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"postConnection"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"before"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"after"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"first"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"last"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sort"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"filter"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PostFilter"}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PostConnection"}}}}]},{"kind":"InputObjectTypeDefinition","name":{"kind":"Name","value":"DocumentFilter"},"fields":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"post"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PostFilter"}}}]},{"kind":"ObjectTypeDefinition","interfaces":[],"directives":[],"name":{"kind":"Name","value":"DocumentConnectionEdges"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"cursor"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"node"},"arguments":[],"type":{"kind":"NamedType","name":{"kind":"Name","value":"DocumentNode"}}}]},{"kind":"ObjectTypeDefinition","interfaces":[{"kind":"NamedType","name":{"kind":"Name","value":"Connection"}}],"directives":[],"name":{"kind":"Name","value":"DocumentConnection"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"pageInfo"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PageInfo"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"totalCount"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"edges"},"arguments":[],"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DocumentConnectionEdges"}}}}]},{"kind":"ObjectTypeDefinition","interfaces":[],"directives":[],"name":{"kind":"Name","value":"Collection"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"name"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"slug"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"label"},"arguments":[],"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"path"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"format"},"arguments":[],"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"matches"},"arguments":[],"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"templates"},"arguments":[],"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"fields"},"arguments":[],"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"documents"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"before"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"after"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"first"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"last"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sort"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"filter"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"DocumentFilter"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"folder"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DocumentConnection"}}}}]},{"kind":"UnionTypeDefinition","name":{"kind":"Name","value":"DocumentNode"},"directives":[],"types":[{"kind":"NamedType","name":{"kind":"Name","value":"Post"}},{"kind":"NamedType","name":{"kind":"Name","value":"Folder"}}]},{"kind":"ObjectTypeDefinition","interfaces":[{"kind":"NamedType","name":{"kind":"Name","value":"Node"}},{"kind":"NamedType","name":{"kind":"Name","value":"Document"}}],"directives":[],"name":{"kind":"Name","value":"Post"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"title"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"body"},"arguments":[],"type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"id"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"_sys"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SystemInfo"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"_values"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}]},{"kind":"InputObjectTypeDefinition","name":{"kind":"Name","value":"StringFilter"},"fields":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"startsWith"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"eq"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"exists"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"in"},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}]},{"kind":"InputObjectTypeDefinition","name":{"kind":"Name","value":"RichTextFilter"},"fields":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"startsWith"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"eq"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"exists"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}]},{"kind":"InputObjectTypeDefinition","name":{"kind":"Name","value":"PostBodyPostPreviewFilter"},"fields":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"StringFilter"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"children"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"RichTextFilter"}}}]},{"kind":"InputObjectTypeDefinition","name":{"kind":"Name","value":"PostBodyFilter"},"fields":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"PostPreview"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PostBodyPostPreviewFilter"}}}]},{"kind":"InputObjectTypeDefinition","name":{"kind":"Name","value":"PostFilter"},"fields":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"StringFilter"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"body"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PostBodyFilter"}}}]},{"kind":"ObjectTypeDefinition","interfaces":[],"directives":[],"name":{"kind":"Name","value":"PostConnectionEdges"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"cursor"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"node"},"arguments":[],"type":{"kind":"NamedType","name":{"kind":"Name","value":"Post"}}}]},{"kind":"ObjectTypeDefinition","interfaces":[{"kind":"NamedType","name":{"kind":"Name","value":"Connection"}}],"directives":[],"name":{"kind":"Name","value":"PostConnection"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"pageInfo"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PageInfo"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"totalCount"},"arguments":[],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"edges"},"arguments":[],"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PostConnectionEdges"}}}}]},{"kind":"ObjectTypeDefinition","interfaces":[],"directives":[],"name":{"kind":"Name","value":"Mutation"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"addPendingDocument"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"collection"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"relativePath"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"template"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DocumentNode"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"updateDocument"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"collection"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"relativePath"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"params"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DocumentUpdateMutation"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DocumentNode"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"deleteDocument"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"collection"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"relativePath"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DocumentNode"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"createDocument"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"collection"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"relativePath"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"params"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DocumentMutation"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DocumentNode"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"createFolder"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"collection"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"relativePath"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DocumentNode"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"updatePost"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"relativePath"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"params"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PostMutation"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Post"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"createPost"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"relativePath"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"params"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PostMutation"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Post"}}}}]},{"kind":"InputObjectTypeDefinition","name":{"kind":"Name","value":"DocumentUpdateMutation"},"fields":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"post"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PostMutation"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"relativePath"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}]},{"kind":"InputObjectTypeDefinition","name":{"kind":"Name","value":"DocumentMutation"},"fields":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"post"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PostMutation"}}}]},{"kind":"InputObjectTypeDefinition","name":{"kind":"Name","value":"PostMutation"},"fields":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"body"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}]}]}} \ No newline at end of file diff --git a/packages/@tinacms/astro/src/__tests__/TinaMarkdown.test.ts b/packages/@tinacms/astro/src/__tests__/TinaMarkdown.test.ts index ee659a1259..e1aa6c7cb4 100644 --- a/packages/@tinacms/astro/src/__tests__/TinaMarkdown.test.ts +++ b/packages/@tinacms/astro/src/__tests__/TinaMarkdown.test.ts @@ -86,6 +86,74 @@ describe('TinaMarkdown', () => { }); }); +/** + * Characterises what this renderer does with `html` / `html_inline` nodes, so + * the behaviour is pinned while `tinacms` and `@tinacms/web-components` are + * brought into line. The same cases exist in those packages' suites. + */ +describe('TinaMarkdown — raw HTML nodes', () => { + it('does not emit markup for a block html node', async () => { + const html = await render({ + props: { + content: [ + { + type: 'html', + value: '

hi

', + }, + ], + }, + }); + + expect(html).not.toContain('
'); + expect(html).toContain('<div id="raw">'); + }); + + it('does not emit markup for an inline html node', async () => { + const html = await render({ + props: { + content: [ + { + type: 'p', + children: [ + { type: 'text', text: 'Some ' }, + { type: 'html_inline', value: 'bold' }, + { type: 'text', text: ' inline.' }, + ], + }, + ], + }, + }); + + expect(html).not.toContain('bold'); + expect(html).toContain('<b>bold</b>'); + }); + + it('does not emit an element carrying an inline event handler', async () => { + const html = await render({ + props: { + content: [ + { type: 'html', value: '' }, + ], + }, + }); + + expect(html).not.toContain('hi
' }], + components: { html: RawHtml.default }, + }, + }); + + expect(html).toContain('
hi
'); + }); +}); + describe('TinaMarkdown — tables', () => { it('renders a native table node with rows, cells and column alignment', async () => { const html = await render({ props: { content: table } }); diff --git a/packages/@tinacms/astro/src/__tests__/fixtures/RawHtml.astro b/packages/@tinacms/astro/src/__tests__/fixtures/RawHtml.astro new file mode 100644 index 0000000000..83841e929e --- /dev/null +++ b/packages/@tinacms/astro/src/__tests__/fixtures/RawHtml.astro @@ -0,0 +1,9 @@ +--- +interface Props { + value: string; +} + +const { value } = Astro.props; +--- + +
diff --git a/packages/@tinacms/cli/package.json b/packages/@tinacms/cli/package.json index 672d83edff..5958220b75 100644 --- a/packages/@tinacms/cli/package.json +++ b/packages/@tinacms/cli/package.json @@ -78,7 +78,6 @@ "@tinacms/search": "workspace:^", "@vitejs/plugin-react": "catalog:", "altair-express-middleware": "catalog:", - "async-lock": "catalog:", "auto-bind": "catalog:", "body-parser": "catalog:", "busboy": "catalog:", diff --git a/packages/@tinacms/cli/src/next/codegen/index.test.ts b/packages/@tinacms/cli/src/next/codegen/index.test.ts index f9ca464231..42ad17e993 100644 --- a/packages/@tinacms/cli/src/next/codegen/index.test.ts +++ b/packages/@tinacms/cli/src/next/codegen/index.test.ts @@ -18,8 +18,8 @@ jest.mock('esbuild', () => ({ })); import path from 'path'; -import * as stripModule from './stripSearchTokenFromConfig'; import { Codegen } from './index'; +import * as stripModule from './stripSearchTokenFromConfig'; describe('Codegen.genClient', () => { function makeInstance(isTs: boolean): Codegen { diff --git a/packages/@tinacms/cli/src/next/codegen/index.ts b/packages/@tinacms/cli/src/next/codegen/index.ts index 4ae31cd019..75e02520c7 100644 --- a/packages/@tinacms/cli/src/next/codegen/index.ts +++ b/packages/@tinacms/cli/src/next/codegen/index.ts @@ -1,13 +1,13 @@ -import fs from 'fs-extra'; import path from 'path'; -import { buildASTSchema, printSchema } from 'graphql'; -import type { GraphQLSchema, DocumentNode } from 'graphql'; -import { generateTypes } from './codegen'; -import { transform } from 'esbuild'; -import { ConfigManager } from '../config-manager'; -import type { TinaSchema } from '@tinacms/schema-tools'; import { mapUserFields } from '@tinacms/graphql'; +import type { TinaSchema } from '@tinacms/schema-tools'; +import { transform } from 'esbuild'; +import fs from 'fs-extra'; +import { buildASTSchema, printSchema } from 'graphql'; +import type { DocumentNode, GraphQLSchema } from 'graphql'; import normalizePath from 'normalize-path'; +import { ConfigManager } from '../config-manager'; +import { generateTypes } from './codegen'; import { stripSearchTokenFromConfig } from './stripSearchTokenFromConfig'; export const TINA_HOST = 'content.tinajs.io'; @@ -215,6 +215,7 @@ export class Codegen { } return apiURL; } + private _createApiUrl() { const branch = this.configManager.config?.branch; const clientId = this.configManager.config?.clientId; diff --git a/packages/@tinacms/cli/src/next/commands/dev-command/index.ts b/packages/@tinacms/cli/src/next/commands/dev-command/index.ts index bb4d16ca01..326b1b7b03 100644 --- a/packages/@tinacms/cli/src/next/commands/dev-command/index.ts +++ b/packages/@tinacms/cli/src/next/commands/dev-command/index.ts @@ -2,10 +2,10 @@ import path from 'path'; import { Database, FilesystemBridge, buildSchema } from '@tinacms/graphql'; import { Telemetry } from '@tinacms/metrics'; import { LocalSearchIndexClient, SearchIndexer } from '@tinacms/search'; -import AsyncLock from 'async-lock'; import chokidar from 'chokidar'; import { Command, Option } from 'clipanion'; import fs from 'fs-extra'; +import { AsyncLock } from 'tinacms/dist/client'; import { logger, summary } from '../../../logger'; import { isHostExposed } from '../../../utils/host'; import { spin } from '../../../utils/spinner'; diff --git a/packages/@tinacms/cli/src/next/commands/dev-command/server/index.ts b/packages/@tinacms/cli/src/next/commands/dev-command/server/index.ts index 5900843ea7..0b84ad33ab 100644 --- a/packages/@tinacms/cli/src/next/commands/dev-command/server/index.ts +++ b/packages/@tinacms/cli/src/next/commands/dev-command/server/index.ts @@ -1,7 +1,6 @@ -import AsyncLock from 'async-lock'; +import type { Database } from '@tinacms/graphql'; import { createServer as createViteServer } from 'vite'; import type { Plugin } from 'vite'; -import type { Database } from '@tinacms/graphql'; import { ConfigManager } from '../../../config-manager'; import { createConfig } from '../../../vite'; import { diff --git a/packages/@tinacms/cli/src/next/vite/plugins.ts b/packages/@tinacms/cli/src/next/vite/plugins.ts index 7e90176b76..f1d7e46a0c 100644 --- a/packages/@tinacms/cli/src/next/vite/plugins.ts +++ b/packages/@tinacms/cli/src/next/vite/plugins.ts @@ -4,7 +4,6 @@ import { FilterPattern, createFilter } from '@rollup/pluginutils'; import type { Config } from '@svgr/core'; import { resolve as gqlResolve } from '@tinacms/graphql'; import type { Database } from '@tinacms/graphql'; -import AsyncLock from 'async-lock'; import bodyParser from 'body-parser'; import cors from 'cors'; import { transform as esbuildTransform } from 'esbuild'; diff --git a/packages/@tinacms/cli/tsconfig.json b/packages/@tinacms/cli/tsconfig.json index 40a0572155..6cb098e7a4 100644 --- a/packages/@tinacms/cli/tsconfig.json +++ b/packages/@tinacms/cli/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../base.tsconfig.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src", + "rootDir": "src" }, "exclude": [ "node_modules", @@ -16,4 +16,4 @@ "include": [ "src" ] -} \ No newline at end of file +} diff --git a/packages/@tinacms/scripts/src/index.ts b/packages/@tinacms/scripts/src/index.ts index 2426c44bc3..6db22db296 100644 --- a/packages/@tinacms/scripts/src/index.ts +++ b/packages/@tinacms/scripts/src/index.ts @@ -355,6 +355,20 @@ export class BuildTina { return; } + // @tinacms/web-components is designed to be imported in plain JS sites. + // It needs to be bundled for browsers. + if (['@tinacms/web-components'].includes(packageJSON.name)) { + await esbuild({ + entryPoints: [path.join(process.cwd(), entry)], + bundle: true, + platform: 'browser', + target: 'esnext', + format: 'esm', + outfile: path.join(process.cwd(), 'dist', `${outInfo.outfile}.js`), + }); + return true; + } + // Rollup requires globals for UMD externals — using 'NOOP' as a dummy to silence warnings. // This has no effect unless UMD is run in a browser. external.forEach((ext) => (globals[ext] = 'NOOP')); diff --git a/packages/@tinacms/web-components/README.md b/packages/@tinacms/web-components/README.md new file mode 100644 index 0000000000..e828493ff6 --- /dev/null +++ b/packages/@tinacms/web-components/README.md @@ -0,0 +1,201 @@ +# @tinacms/web-components + +All things web components with Tina. + +This package is used to add visual editing support and markdown rendering to +plain JS sites. + + +## Install + +```bash +pnpm add @tinacms/web-components +pnpm add -D @tinacms/cli tinacms +``` + + +## Making Tina requests + +Both `tinacms dev` / `tinacms build` will generate a JS client to +`./tina/__generated__/client.js`. This is used to fetch your markdown content. + +For example, given a collection `post`, the generated Tina client will provide +a `postConnection` method which can be used to fetch "post" content. + +```javascript +import {client} from "./tina/__generated__/client.js"; + +const postsResponse = await client.queries.postConnection(); +const posts = postsResponse.data.postConnection.edges.map((post) => { + return { + title: post.node.title, + body: post.node.body, + }; +}); +``` + +If your build does not bundle JS, an +[importmap](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap) +will need to be setup. This is because the generated Tina client imports a bare +specifier that the browser can't resolve. + +```html + + + +``` + +For more details on querying content, please visit the ["Querying Content" +section of the Tina docs](https://tina.io/docs/features/data-fetching). + + +## Rendering Markdown + +The `tina-markdown` web component is used to render markdown from a [Tina +`rich-text` field](https://tina.io/docs/reference/types/rich-text). + +It receives the stringified AST provided by the `rich-text` field via the +`content` attribute. + +```html + +
    + + + +``` + +### Raw HTML + +Raw HTML in a `rich-text` field is first sanitized via [the DOMPurify +package](https://github.com/cure53/dompurify) before being rendered. To see +what gets stripped, please refer to [the DOMPurify +documentation](https://github.com/cure53/dompurify#some-purification-samples-please). + +### Custom components + +Custom components can be registered via the `TinaMarkdown.components` object. +This allows you to render custom html or web-components via MDX. + +Custom components **must** be registered before the `tina-markdown` component +connects, e.g. before render. + +For more information on how to register custom components in your schema, refer +to the [Field with custom component +documentation](https://tina.io/docs/reference/types/rich-text#field-with-custom-component-mdx). + +```js +import {TinaMarkdown} from "./node_modules/@tinacms/web-components/dist/tina-markdown.js"; + +TinaMarkdown.components = { + "PostPreview": (node) => { + const el = document.createElement("post-preview"); + + const title = document.createElement("span"); + title.slot = "title"; + title.textContent = node.props.title ?? ""; + el.append(title); + + return el; + }, +}; +``` + + +## Visual Editing + +Visual editing can be setup to allow live editing of a collection on a page. + +Before the following works, [setup a visual editing +router](https://tina.io/docs/contextual-editing/router) for the desired +collection. + +Building off the previous example, the following shows the inclusion of visual +editing. + +```html + +
      + + + +``` + +A more detailed example can be found in the [kitchen-sink +example](https://github.com/tinacms/tinacms/tree/main/examples/web-components/kitchen-sink) + + +## License + +Apache 2.0 diff --git a/packages/@tinacms/web-components/package.json b/packages/@tinacms/web-components/package.json new file mode 100644 index 0000000000..62b61609c3 --- /dev/null +++ b/packages/@tinacms/web-components/package.json @@ -0,0 +1,40 @@ +{ + "name": "@tinacms/web-components", + "version": "0.1.0", + "type": "module", + "files": [ + "package.json", + "src", + "dist" + ], + "license": "Apache-2.0", + "buildConfig": { + "entryPoints": [ + "./src/tina-markdown.js", + "./src/visual-editing.js" + ] + }, + "scripts": { + "types": "tsc", + "build": "tinacms-scripts build", + "test": "vitest run", + "test-watch": "vitest" + }, + "dependencies": { + "@tinacms/bridge": "workspace:^", + "dompurify": "catalog:" + }, + "devDependencies": { + "@tinacms/scripts": "workspace:*", + "happy-dom": "catalog:", + "vite": "^5.4.14", + "vitest": "^2.1.9" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org" + }, + "repository": { + "url": "https://github.com/tinacms/tinacms.git", + "directory": "packages/@tinacms/web-components" + } +} diff --git a/packages/@tinacms/web-components/src/tina-markdown.js b/packages/@tinacms/web-components/src/tina-markdown.js new file mode 100644 index 0000000000..8589b13838 --- /dev/null +++ b/packages/@tinacms/web-components/src/tina-markdown.js @@ -0,0 +1,179 @@ +import DOMPurify from 'dompurify'; + +/** + * @typedef {Object} Node + * @property {string} type + * @property {string} name + * @property {Node[]} children + */ + +const TAGS = { + h1: 'h1', + h2: 'h2', + h3: 'h3', + h4: 'h4', + h5: 'h5', + h6: 'h6', + p: 'p', + ol: 'ol', + ul: 'ul', + li: 'li', + lic: 'div', + blockquote: 'blockquote', + img: 'img', + a: 'a', + code_block: 'code', + hr: 'hr', + break: 'br', + table: 'table', // TODO: does not support alignment. + tr: 'tr', + td: 'td', + invalid_markdown: 'pre', + html: 'html', + html_inline: 'html', + mdxJsxTextElement: 'div', + mdxJsxFlowElement: 'div', +}; +const MARKS = [ + ['bold', 'strong'], + ['italic', 'em'], + ['underline', 'u'], + ['strikethrough', 's'], + ['code', 'code'], + ['highlight', 'mark'], +]; + +/** + * @param {Node} root + * @returns {HTMLElement} + */ +function renderRichText(root) { + const container = document.createElement('div'); + + for (const block of root.children ?? []) { + container.appendChild(renderNode(block)); + } + + return container; +} + +/** + * @param {Node} node + * @returns {HTMLElement} + */ +function renderNode(node) { + if (node.type === 'text') return renderText(node); + + if (node.type === 'html' || node.type === 'html_inline') { + return DOMPurify.sanitize(node.value, { RETURN_DOM_FRAGMENT: true }); + } + + const tag = TAGS[node.type]; + /** @type {HTMLElement} */ + const el = document.createElement(tag); + + if (node.url && node.type === 'a') el.href = node.url; + if (node.url && node.type === 'img') el.src = node.url; + + if (node.type === 'mdxJsxTextElement' || node.type === 'mdxJsxFlowElement') { + const override = TinaMarkdown.components[node.name]; + if (override) return override(node); + return el; + } + + if (node.type === 'code_block') { + const pre = document.createElement('pre'); + + let codeString = ''; + if (Array.isArray(node.children)) { + codeString = node.children + .map((line) => + Array.isArray(line.children) + ? line.children.map((t) => t.text).join('') + : '' + ) + .join('\n'); + } else if (typeof node.value === 'string') { + codeString = node.value; + } + el.innerText = codeString; + if (node.lang) el.setAttribute('lang', node.lang); + + pre.appendChild(el); + return pre; + } + + if (node.type === 'table') { + const table_body = document.createElement('tbody'); + for (const child of node.children ?? []) { + table_body.appendChild(renderNode(child)); + } + el.appendChild(table_body); + return el; + } + + for (const child of node.children ?? []) { + el.appendChild(renderNode(child)); + } + + return el; +} + +/** + * @param {Node} node + * @returns {Text} + */ +function renderText(node) { + let el = document.createTextNode(node.text); + for (const [prop, tag] of MARKS) { + if (node[prop]) { + const wrapper = document.createElement(tag); + wrapper.appendChild(el); + el = wrapper; + } + } + return el; +} + +export class TinaMarkdown extends HTMLElement { + /** + * @type {Object.} + * Register custom components. + * + * @example + * import {TinaMarkdown} from "./node_modules/@tinacms/web-components/dist/tina-markdown.js"; + * + * TinaMarkdown.components = { + * "PostPreview": (node) => { + * const el = document.createElement("post-preview"); + * + * const title = document.createElement("span"); + * title.slot = "title"; + * title.textContent = node.props.title ?? ""; + * el.append(title); + * + * return el; + * }, + * }; + */ + static components = {}; + + constructor() { + super(); + + this.attachShadow({ + mode: 'open', + }); + } + + connectedCallback() { + /** @type {string} */ + const content = this.getAttribute('content'); + /** @type {Node} */ + const ast = JSON.parse(content); + + this.shadowRoot.appendChild(renderRichText(ast)); + } +} + +customElements.define('tina-markdown', TinaMarkdown); diff --git a/packages/@tinacms/web-components/src/tina-markdown.test.ts b/packages/@tinacms/web-components/src/tina-markdown.test.ts new file mode 100644 index 0000000000..f44677636d --- /dev/null +++ b/packages/@tinacms/web-components/src/tina-markdown.test.ts @@ -0,0 +1,421 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { TinaMarkdown } from './tina-markdown.js'; + +function render(content: unknown): ShadowRoot { + const el = document.createElement('tina-markdown'); + el.setAttribute('content', JSON.stringify(content)); + document.body.appendChild(el); + return el.shadowRoot as ShadowRoot; +} + +describe('tina-markdown', () => { + it('renders a paragraph of text', () => { + const root = render({ + type: 'root', + children: [ + { type: 'p', children: [{ type: 'text', text: 'Hello world' }] }, + ], + }); + + const p = root.querySelector('p'); + expect(p).not.toBeNull(); + expect(p?.textContent).toBe('Hello world'); + }); + + it('maps headings h1-h6 to their tags', () => { + const root = render({ + type: 'root', + children: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].map((type) => ({ + type, + children: [{ type: 'text', text: type }], + })), + }); + + for (const type of ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']) { + expect(root.querySelector(type)?.textContent).toBe(type); + } + }); + + it('wraps text in the correct mark elements', () => { + const cases = [ + ['bold', 'STRONG'], + ['italic', 'EM'], + ['underline', 'U'], + ['strikethrough', 'S'], + ['code', 'CODE'], + ['highlight', 'MARK'], + ] as const; + + for (const [mark, tag] of cases) { + const root = render({ + type: 'root', + children: [ + { + type: 'p', + children: [{ type: 'text', text: 'text', [mark]: true }], + }, + ], + }); + + const firstChild = root.querySelector('p')?.firstElementChild; + expect(firstChild?.tagName, `${mark} should map to <${tag}>`).toBe(tag); + expect(firstChild?.textContent).toBe('text'); + } + }); + + it('nests marks with later marks wrapping earlier ones', () => { + const root = render({ + type: 'root', + children: [ + { + type: 'p', + children: [{ type: 'text', text: 'x', bold: true, italic: true }], + }, + ], + }); + + const p = root.querySelector('p'); + expect(p?.firstElementChild?.tagName).toBe('EM'); + expect(p?.firstElementChild?.firstElementChild?.tagName).toBe('STRONG'); + expect(p?.textContent).toBe('x'); + }); + + it('renders links with an href', () => { + const root = render({ + type: 'root', + children: [ + { + type: 'a', + url: 'https://example.com', + children: [{ type: 'text', text: 'link' }], + }, + ], + }); + + const a = root.querySelector('a'); + expect(a?.getAttribute('href')).toBe('https://example.com'); + expect(a?.textContent).toBe('link'); + }); + + it('renders images with a src', () => { + const root = render({ + type: 'root', + children: [{ type: 'img', url: '/image.png' }], + }); + + expect(root.querySelector('img')?.getAttribute('src')).toBe('/image.png'); + }); + + it('renders ordered and unordered lists', () => { + const root = render({ + type: 'root', + children: [ + { + type: 'ul', + children: [{ type: 'li', children: [{ type: 'text', text: 'a' }] }], + }, + { + type: 'ol', + children: [{ type: 'li', children: [{ type: 'text', text: 'b' }] }], + }, + ], + }); + + expect(root.querySelector('ul li')?.textContent).toBe('a'); + expect(root.querySelector('ol li')?.textContent).toBe('b'); + }); + + it('renders code blocks in a pre > code with the lang attribute', () => { + const root = render({ + type: 'root', + children: [{ type: 'code_block', lang: 'js', value: 'const x = 1;' }], + }); + + const pre = root.querySelector('pre'); + const code = pre?.querySelector('code'); + expect(pre).not.toBeNull(); + expect(code?.getAttribute('lang')).toBe('js'); + expect(code?.textContent).toBe('const x = 1;'); + }); + + it('joins multi-line code children with line breaks', () => { + const root = render({ + type: 'root', + children: [ + { + type: 'code_block', + lang: 'js', + children: [ + { type: 'p', children: [{ type: 'text', text: 'line one' }] }, + { type: 'p', children: [{ type: 'text', text: 'line two' }] }, + ], + }, + ], + }); + + const code = root.querySelector('code'); + expect(code?.innerHTML).toContain('line one'); + expect(code?.innerHTML).toContain('line two'); + expect(code?.innerHTML).toContain('
      '); + }); + + it('renders tables inside a tbody', () => { + const root = render({ + type: 'root', + children: [ + { + type: 'table', + children: [ + { + type: 'tr', + children: [ + { type: 'td', children: [{ type: 'text', text: 'cell' }] }, + ], + }, + ], + }, + ], + }); + + const table = root.querySelector('table'); + expect(table?.querySelector('tbody')).not.toBeNull(); + expect(table?.querySelector('tr td')?.textContent).toBe('cell'); + }); + + it('renders blockquotes', () => { + const root = render({ + type: 'root', + children: [ + { type: 'blockquote', children: [{ type: 'text', text: 'quote' }] }, + ], + }); + + expect(root.querySelector('blockquote')?.textContent).toBe('quote'); + }); + + it('passes raw html through', () => { + const root = render({ + type: 'root', + children: [{ type: 'html', value: 'x' }], + }); + + expect(root.querySelector('strong')?.textContent).toBe('x'); + }); + + it('passes inline html through', () => { + const root = render({ + type: 'root', + children: [{ type: 'html_inline', value: 'y' }], + }); + + expect(root.querySelector('em')?.textContent).toBe('y'); + }); + + it('renders invalid markdown as pre', () => { + const root = render({ + type: 'root', + children: [ + { + type: 'invalid_markdown', + children: [{ type: 'text', text: 'garbage' }], + }, + ], + }); + + expect(root.querySelector('pre')?.textContent).toBe('garbage'); + }); + + it('renders hr and break elements', () => { + const root = render({ + type: 'root', + children: [{ type: 'hr' }, { type: 'break' }], + }); + + expect(root.querySelector('hr')).not.toBeNull(); + expect(root.querySelector('br')).not.toBeNull(); + }); + + it('wraps all root children in a single container, preserving order', () => { + const root = render({ + type: 'root', + children: [ + { type: 'h1', children: [{ type: 'text', text: 'Title' }] }, + { type: 'p', children: [{ type: 'text', text: 'Body' }] }, + ], + }); + + expect(root.childNodes.length).toBe(1); + expect(root.firstElementChild?.tagName).toBe('DIV'); + expect(root.textContent).toBe('TitleBody'); + }); + + it('renders an empty root as an empty container', () => { + const root = render({ type: 'root' }); + + expect(root.childNodes.length).toBe(1); + expect(root.textContent).toBe(''); + }); + + it('throws when the content attribute is not valid JSON', () => { + const el = document.createElement('tina-markdown'); + el.setAttribute('content', 'not json'); + + expect(() => document.body.appendChild(el)).toThrow(); + }); +}); + +/** + * Mirrors `TinaMarkdown raw HTML nodes` in `packages/tinacms` and + * `packages/@tinacms/astro`. The three suites share case names so the + * renderers can be compared side by side; where an expectation here differs + * from the other two, the renderers disagree. + * + * One deliberate divergence: the web component has no `components.html` + * opt-in. Raw HTML is always sanitised, so where the other two renderers have + * a positive "opt in" case this suite asserts the sanitisation cannot be + * bypassed, and that the `components` map is keyed by MDX component name only. + */ +describe('tina-markdown raw HTML nodes', () => { + afterEach(() => { + TinaMarkdown.components = {}; + }); + + it('renders the markup of a block html node', () => { + const root = render({ + type: 'root', + children: [ + { + type: 'html', + value: '

      hi

      ', + }, + ], + }); + + expect(root.querySelector('#raw > center > p')?.textContent).toBe('hi'); + }); + + it('renders an inline html node inline within its paragraph', () => { + const root = render({ + type: 'root', + children: [ + { + type: 'p', + children: [ + { type: 'text', text: 'Some ' }, + { type: 'html_inline', value: 'bold' }, + { type: 'text', text: ' inline.' }, + ], + }, + ], + }); + + expect(root.querySelector('p > b')?.textContent).toBe('bold'); + expect(root.querySelector('p')?.textContent).toBe('Some bold inline.'); + }); + + it('does not wrap an inline html node in a block element', () => { + const root = render({ + type: 'root', + children: [ + { + type: 'p', + children: [{ type: 'html_inline', value: 'bold' }], + }, + ], + }); + + expect(root.querySelector('p > div')).toBeNull(); + }); + + it('strips an inline event handler while keeping the element', () => { + const root = render({ + type: 'root', + children: [ + { type: 'html', value: '' }, + ], + }); + + const img = root.querySelector('img'); + expect(img?.getAttribute('src')).toBe('x'); + expect(img?.getAttribute('onerror')).toBeNull(); + }); + + it('drops a script element', () => { + const root = render({ + type: 'root', + children: [ + { + type: 'html', + value: '

      before

      ', + }, + ], + }); + + expect(root.querySelector('script')).toBeNull(); + expect(root.querySelector('p')?.textContent).toBe('before'); + }); + + it('does not let components.html bypass sanitisation', () => { + TinaMarkdown.components = { + html: (node: { value: string }) => { + const el = document.createElement('div'); + el.innerHTML = node.value; + return el; + }, + }; + + const root = render({ + type: 'root', + children: [{ type: 'html', value: 'raw' }], + }); + + const b = root.querySelector('b'); + expect(b?.textContent).toBe('raw'); + expect(b?.getAttribute('onclick')).toBeNull(); + }); + + it('does not route node types through the components map', () => { + TinaMarkdown.components = { + h1: (node: { children: { text: string }[] }) => { + const el = document.createElement('h1'); + el.className = 'fancy'; + el.textContent = node.children[0].text; + return el; + }, + }; + + const root = render({ + type: 'root', + children: [{ type: 'h1', children: [{ type: 'text', text: 'Title' }] }], + }); + + const h1 = root.querySelector('h1'); + expect(h1?.textContent).toBe('Title'); + expect(h1?.className).toBe(''); + }); + + it('routes mdx custom elements through components by name', () => { + TinaMarkdown.components = { + PostPreview: (node: { props: { title: string } }) => { + const el = document.createElement('div'); + el.className = 'preview'; + el.textContent = node.props.title; + return el; + }, + }; + + const root = render({ + type: 'root', + children: [ + { + type: 'mdxJsxFlowElement', + name: 'PostPreview', + props: { title: 'Hello' }, + }, + ], + }); + + const preview = root.querySelector('.preview'); + expect(preview?.textContent).toBe('Hello'); + }); +}); diff --git a/packages/@tinacms/web-components/src/visual-editing.js b/packages/@tinacms/web-components/src/visual-editing.js new file mode 100644 index 0000000000..bb453f990c --- /dev/null +++ b/packages/@tinacms/web-components/src/visual-editing.js @@ -0,0 +1,136 @@ +import { tinaField } from '@tinacms/bridge'; +export { tinaField }; + +import { + QUICK_EDIT_BODY_CLASS, + QUICK_EDIT_CSS, +} from '@tinacms/bridge/quick-edit-css'; + +import { addMetadata, hashFromQuery } from '@tinacms/bridge/metadata'; + +/** + * @typedef {'open' | 'quick-edit' | 'quickEditEnabled' | 'field:selected' | 'close'} type + */ + +/** + * @typedef {Object} CreateTinaOptions + * @property {() => Promise<{data: object, query: string, variables: object}>} query + * A generated-client query (e.g. `() => client.queries.postConnection()`). + * @property {(data: object) => void} render + * Rebuilds the page DOM from metadata-stamped data. Called with the + * initial fetch result and again with every admin `updateData` payload. + */ + +/** + * Create a visual-editing session for one page. + * @param {CreateTinaOptions} options + */ +export function createTina({ query, render }) { + let id = null; + let quickEditEnabled = false; + + async function init() { + const result = await query(); + id = hashFromQuery( + JSON.stringify({ query: result.query, variables: result.variables }) + ); + + // Stamp every object in the result with `_content_source` so `tinaField()` + // can derive `data-tina-field` values from it. The stamp mutates the data + // shape, so only the render path sees it - the `open` message carries the + // raw result instead. + render(addMetadata(id, structuredClone(result.data), [])); + + post({ type: 'open', id, ...result }); + + window.addEventListener('message', onMessage); + window.addEventListener('beforeunload', onBeforeUnload); + } + + function onMessage(event) { + if (!isFromAdmin(event)) return; + + if (event.data.type === 'quickEditEnabled') { + setQuickEditEnabled(event.data.value); + } + + if (event.data.id === id && event.data.type === 'updateData') { + render(addMetadata(id, structuredClone(event.data.data), [])); + reportQuickEdit(); + } + } + + function setQuickEditEnabled(enabled) { + if (enabled === quickEditEnabled) return; + quickEditEnabled = enabled; + + if (enabled) { + injectQuickEditCss(); + document.addEventListener('click', onQuickEditClick, true); + } else { + removeQuickEditCss(); + document.removeEventListener('click', onQuickEditClick, true); + } + } + + // Capture-phase listener so the click never reaches the page's own handlers. + // Walks the composed path rather than `closest()`: `tina-markdown` renders + // into an open shadow root, and only the composed path (which includes the + // shadow host) can be searched for the `data-tina-field` marker. + function onQuickEditClick(event) { + const fieldName = resolveFieldName(event.composedPath()); + if (!fieldName) return; + + event.preventDefault(); + event.stopPropagation(); + post({ type: 'field:selected', fieldName }); + } + + function onBeforeUnload() { + if (id) post({ type: 'close', id }); + } + + function reportQuickEdit() { + const hasMarkers = !!document.querySelector('[data-tina-field]'); + post({ type: 'quick-edit', value: hasMarkers }); + } + + function post(message) { + window.parent.postMessage(message, window.location.origin); + } + + function isFromAdmin(event) { + if (event.source !== window.parent) return false; + if (event.origin !== window.location.origin) return false; + return true; + } + + return { init }; +} + +function resolveFieldName(path) { + for (const node of path) { + if (!(node instanceof Element)) continue; + const attributeName = node + .getAttributeNames() + .find((name) => name.startsWith('data-tina-field')); + if (attributeName) { + const value = node.getAttribute(attributeName); + if (value) return value; + } + } + return null; +} + +function injectQuickEditCss() { + const style = document.createElement('style'); + style.textContent = QUICK_EDIT_CSS; + style.id = 'tina-quick-edit-style'; + document.head.appendChild(style); + document.body.classList.add(QUICK_EDIT_BODY_CLASS); +} + +function removeQuickEditCss() { + document.getElementById('tina-quick-edit-style')?.remove(); + document.body.classList.remove(QUICK_EDIT_BODY_CLASS); +} diff --git a/packages/@tinacms/web-components/tsconfig.json b/packages/@tinacms/web-components/tsconfig.json new file mode 100644 index 0000000000..faa59192da --- /dev/null +++ b/packages/@tinacms/web-components/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../base.tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true, + "noImplicitAny": true, + "noUncheckedIndexedAccess": true, + "noFallthroughCasesInSwitch": true, + "moduleResolution": "bundler", + "module": "esnext", + "target": "esnext", + "lib": ["ESNext", "DOM", "DOM.Iterable"] + }, + "exclude": ["dist", "src/**/*.test.ts", "src/__tests__"], + "include": ["src"] +} diff --git a/packages/@tinacms/web-components/vitest.config.ts b/packages/@tinacms/web-components/vitest.config.ts new file mode 100644 index 0000000000..c86cb255ac --- /dev/null +++ b/packages/@tinacms/web-components/vitest.config.ts @@ -0,0 +1,10 @@ +/// +import { defineConfig } from 'vite'; + +export default defineConfig({ + test: { + globals: true, + environment: 'happy-dom', + include: ['src/**/*.test.ts'], + }, +}); diff --git a/packages/tinacms/package.json b/packages/tinacms/package.json index 28285798ae..ff5148896b 100644 --- a/packages/tinacms/package.json +++ b/packages/tinacms/package.json @@ -86,7 +86,6 @@ "@udecode/plate-slash-command": "catalog:", "@udecode/plate-table": "catalog:", "@udecode/plate-trailing-block": "catalog:", - "async-lock": "catalog:", "class-variance-authority": "catalog:", "clsx": "catalog:", "cmdk": "catalog:", diff --git a/packages/tinacms/src/rich-text/index.test.tsx b/packages/tinacms/src/rich-text/index.test.tsx index 50c7d76b53..41a390fbca 100644 --- a/packages/tinacms/src/rich-text/index.test.tsx +++ b/packages/tinacms/src/rich-text/index.test.tsx @@ -63,3 +63,82 @@ describe('TinaMarkdown URL sanitization', () => { expect(img?.getAttribute('src')).toBe(''); }); }); + +/** + * Characterises what this renderer does with `html` / `html_inline` nodes, so + * the behaviour is pinned while `@tinacms/astro` and `@tinacms/web-components` + * are brought into line. The same cases exist in those packages' suites. + */ +describe('TinaMarkdown raw HTML nodes', () => { + const renderNode = (node: unknown) => + render() + .container; + + it('does not build DOM from a block html node', () => { + const container = renderNode({ + type: 'html', + value: '

      hi

      ', + }); + + expect(container.querySelector('#raw')).toBeNull(); + expect(container.textContent).toContain('
      '); + }); + + it('does not build DOM from an inline html node', () => { + const container = render( + bold' }, + { type: 'text', text: ' inline.' }, + ], + }, + ], + } as any + } + /> + ).container; + + expect(container.querySelector('b')).toBeNull(); + expect(container.querySelector('p')?.textContent).toBe( + 'Some bold inline.' + ); + }); + + it('does not create an element carrying an inline event handler', () => { + const container = renderNode({ + type: 'html', + value: '', + }); + + expect(container.querySelector('img')).toBeNull(); + }); + + it('renders raw HTML when the consumer opts in via components.html', () => { + const { container } = render( + hi
      ' }], + } as any + } + components={ + { + html: (props: { value: string }) => ( +
      + ), + } as any + } + /> + ); + + expect(container.querySelector('#raw')?.textContent).toBe('hi'); + }); +}); diff --git a/packages/tinacms/src/unifiedClient/asyncLock.test.ts b/packages/tinacms/src/unifiedClient/asyncLock.test.ts new file mode 100644 index 0000000000..cf0f50bad0 --- /dev/null +++ b/packages/tinacms/src/unifiedClient/asyncLock.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { AsyncLock } from './asyncLock'; + +const tick = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +describe('AsyncLock', () => { + it('serializes overlapping acquires for the same key in FIFO order', async () => { + const lock = new AsyncLock(); + const order: string[] = []; + const gate = (() => { + let resolve: () => void; + const blocked = new Promise((r) => (resolve = r)); + return { blocked, release: () => resolve() }; + })(); + + const first = lock.acquire('key', async () => { + order.push('first:start'); + await gate.blocked; + order.push('first:end'); + }); + const second = lock.acquire('key', async () => { + order.push('second:start'); + order.push('second:end'); + }); + + await tick(10); + expect(order).toEqual(['first:start']); + gate.release(); + await Promise.all([first, second]); + expect(order).toEqual([ + 'first:start', + 'first:end', + 'second:start', + 'second:end', + ]); + }); + + it('runs independent keys concurrently', async () => { + const lock = new AsyncLock(); + let running = 0; + let maxRunning = 0; + + await Promise.all( + ['a', 'b', 'c'].map((key) => + lock.acquire(key, async () => { + running += 1; + maxRunning = Math.max(maxRunning, running); + await tick(10); + running -= 1; + }) + ) + ); + + expect(maxRunning).toBe(3); + }); + + it('propagates a rejection to the caller without wedging the queue', async () => { + const lock = new AsyncLock(); + + await expect( + lock.acquire('key', async () => { + throw new Error('boom'); + }) + ).rejects.toThrow('boom'); + + await expect(lock.acquire('key', async () => 42)).resolves.toBe(42); + }); + + it('resolves with the value returned by the acquired fn', async () => { + const lock = new AsyncLock(); + await expect(lock.acquire('key', async () => 'value')).resolves.toBe( + 'value' + ); + }); +}); diff --git a/packages/tinacms/src/unifiedClient/asyncLock.ts b/packages/tinacms/src/unifiedClient/asyncLock.ts new file mode 100644 index 0000000000..e0d2f9017c --- /dev/null +++ b/packages/tinacms/src/unifiedClient/asyncLock.ts @@ -0,0 +1,48 @@ +/** + * The MIT License (MIT) + * + * Copyright (c) 2016 Rogier Schouten + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Vendored subset of async-lock (https://github.com/rogierschouten/async-lock), + * trimmed to the API surface TinaCMS uses: promise-mode `acquire(key, fn)` on a + * single string key. Preserves the upstream semantics — per-key serialization, + * FIFO ordering, and a rejected `fn` rejects the caller without wedging the + * queue for that key. Lives in this package (rather than a shared util) so it + * inlines into `dist/client.js` without dragging in a bundler-hostile CJS dep. + */ +export class AsyncLock { + private queues = new Map>(); + + acquire(key: string, fn: () => Promise): Promise { + const prev = this.queues.get(key) ?? Promise.resolve(); + const next = prev.then(fn); + this.queues.set( + key, + next.then( + () => undefined, + () => undefined + ) + ); + return next; + } +} diff --git a/packages/tinacms/src/unifiedClient/index.ts b/packages/tinacms/src/unifiedClient/index.ts index b1d1cfb29e..1e16e7a67f 100644 --- a/packages/tinacms/src/unifiedClient/index.ts +++ b/packages/tinacms/src/unifiedClient/index.ts @@ -1,7 +1,9 @@ import type { Config } from '@tinacms/schema-tools'; -import AsyncLock from 'async-lock'; import type { GraphQLError } from 'graphql'; import type { Cache } from '../cache/index'; +import { AsyncLock } from './asyncLock'; + +export { AsyncLock } from './asyncLock'; export const TINA_HOST = 'content.tinajs.io'; export interface TinaClientArgs> { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cce12d0836..a0d269d459 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -315,9 +315,6 @@ catalogs: altair-express-middleware: specifier: ^7.3.6 version: 7.3.6 - async-lock: - specifier: ^1.4.1 - version: 1.4.1 auto-bind: specifier: ^4.0.0 version: 4.0.0 @@ -378,6 +375,9 @@ catalogs: crypto-js: specifier: ^4.2.0 version: 4.2.0 + dompurify: + specifier: ^3.3.1 + version: 3.3.1 dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -1188,6 +1188,19 @@ importers: specifier: ^6.0.0 version: 6.4.1(@types/node@25.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) + examples/web-components/kitchen-sink: + dependencies: + '@tinacms/web-components': + specifier: workspace:^ + version: link:../../../packages/@tinacms/web-components + devDependencies: + '@tinacms/cli': + specifier: workspace:* + version: link:../../../packages/@tinacms/cli + tinacms: + specifier: workspace:* + version: link:../../../packages/tinacms + packages/@tinacms/app: dependencies: '@graphiql/toolkit': @@ -1384,9 +1397,6 @@ importers: altair-express-middleware: specifier: 'catalog:' version: 7.3.6 - async-lock: - specifier: 'catalog:' - version: 1.4.1 auto-bind: specifier: 'catalog:' version: 4.0.0 @@ -2002,6 +2012,28 @@ importers: specifier: ^5.7.3 version: 5.9.3 + packages/@tinacms/web-components: + dependencies: + '@tinacms/bridge': + specifier: workspace:^ + version: link:../bridge + dompurify: + specifier: 'catalog:' + version: 3.3.1 + devDependencies: + '@tinacms/scripts': + specifier: workspace:* + version: link:../scripts + happy-dom: + specifier: 'catalog:' + version: 15.10.2 + vite: + specifier: ^5.4.14 + version: 5.4.21(@types/node@25.1.0)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0) + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@25.1.0)(happy-dom@15.10.2)(jsdom@15.2.1(bufferutil@4.1.0))(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0) + packages/@tinacms/webpack-helpers: dependencies: typescript: @@ -2351,9 +2383,6 @@ importers: '@udecode/plate-trailing-block': specifier: 'catalog:' version: 48.0.0(@udecode/plate@48.0.5(@types/react@18.3.27)(immer@10.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(scheduler@0.27.0)(slate-dom@0.114.0(slate@0.114.0))(slate@0.114.0)(use-sync-external-store@1.6.0(react@18.3.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - async-lock: - specifier: 'catalog:' - version: 1.4.1 class-variance-authority: specifier: 'catalog:' version: 0.7.1 @@ -24777,13 +24806,13 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@25.1.0)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0) - '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(vite@6.4.3(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) + vite: 6.4.3(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) '@vitest/pretty-format@2.1.9': dependencies: @@ -34532,23 +34561,6 @@ snapshots: sass: 1.97.3 terser: 5.46.0 - vite@6.4.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.53.4 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 22.19.17 - fsevents: 2.3.3 - jiti: 2.6.1 - lightningcss: 1.32.0 - sass: 1.97.3 - terser: 5.46.0 - yaml: 2.8.2 - vite@6.4.1(@types/node@25.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2): dependencies: esbuild: 0.25.12 @@ -34794,7 +34806,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@6.4.3(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -34812,7 +34824,7 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.4.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) + vite: 6.4.3(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) vite-node: 3.2.4(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5d4ca412ae..ea48da2151 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -143,7 +143,6 @@ catalog: abstract-level: ^1.0.4 acorn: 8.8.2 altair-express-middleware: ^7.3.6 - async-lock: ^1.4.1 auto-bind: ^4.0.0 autoprefixer: ^10.4.20 axios: 1.7.5 @@ -163,6 +162,7 @@ catalog: cors: ^2.8.5 cross-spawn: ^7.0.6 crypto-js: ^4.2.0 + dompurify: ^3.3.1 dotenv: ^16.4.7 esbuild: ^0.28.1 estree-util-is-identifier-name: 2.1.0 diff --git a/tests/build-verification.test.ts b/tests/build-verification.test.ts index 9f123cafcc..257206f243 100644 --- a/tests/build-verification.test.ts +++ b/tests/build-verification.test.ts @@ -95,6 +95,24 @@ for (const pkg of packages) { const { name: pkgName, pkgDir } = pkg describe(pkgName, () => { + // ------------------------------------------------------------------ + // @tinacms/web-components + // ------------------------------------------------------------------ + if (pkgName === '@tinacms/web-components') { + // Consumed as raw browser ESM files from dist/ in plain JS sites + // (no main/exports/types/bin entry points). + it('dist bundles exist for direct browser consumption', () => { + for (const file of ['dist/tina-markdown.js', 'dist/visual-editing.js']) { + expect(fs.existsSync(path.resolve(pkgDir, file))).toBe(true) + } + for (const file of ['dist/tina-markdown.d.ts', 'dist/visual-editing.d.ts']) { + const absPath = path.resolve(pkgDir, file) + expect(fs.existsSync(absPath)).toBe(true) + expect(fs.statSync(absPath).size).toBeGreaterThan(0) + } + }) + } + // ------------------------------------------------------------------ // main // ------------------------------------------------------------------ From 5050709dcbbc99530d6b284021c259d098d6455d Mon Sep 17 00:00:00 2001 From: "Brook Jeynes [SSW]" Date: Mon, 17 Aug 2026 16:06:09 +1000 Subject: [PATCH 5/6] feat: workos redirect auth (#7327) This PR adds support for workos redirect authentication when enabled. Depends on https://github.com/tinacms/tinacms/pull/7304 --------- Assisted-by: OpenCode:big-pickle Signed-off-by: brookjeynes-ssw --- .changeset/common-eyes-hear.md | 7 + .../@tinacms/schema-tools/src/types/index.ts | 2 +- packages/tinacms/src/auth/AuthModal.tsx | 16 +- .../tinacms/src/auth/TinaCloudProvider.tsx | 43 ++- .../tinacms/src/auth/authenticate.test.ts | 230 ++++++++++----- packages/tinacms/src/auth/authenticate.ts | 106 +++++-- packages/tinacms/src/auth/pkce.test.ts | 61 ++++ packages/tinacms/src/auth/pkce.ts | 25 ++ packages/tinacms/src/auth/useGenerator.ts | 40 --- .../src/auth/useTinaAuthRedirect.test.tsx | 270 ++++++++++++++++++ .../tinacms/src/auth/useTinaAuthRedirect.tsx | 100 ++++++- .../src/internalClient/authProvider.test.ts | 2 +- .../src/internalClient/authProvider.ts | 43 +-- .../tinacms/src/internalClient/index.test.ts | 4 +- packages/tinacms/src/internalClient/index.ts | 2 +- 15 files changed, 763 insertions(+), 188 deletions(-) create mode 100644 .changeset/common-eyes-hear.md create mode 100644 packages/tinacms/src/auth/pkce.test.ts create mode 100644 packages/tinacms/src/auth/pkce.ts delete mode 100644 packages/tinacms/src/auth/useGenerator.ts create mode 100644 packages/tinacms/src/auth/useTinaAuthRedirect.test.tsx diff --git a/.changeset/common-eyes-hear.md b/.changeset/common-eyes-hear.md new file mode 100644 index 0000000000..4929847ab6 --- /dev/null +++ b/.changeset/common-eyes-hear.md @@ -0,0 +1,7 @@ +--- +"tinacms": patch +"@tinacms/app": patch +"@tinacms/schema-tools": patch +--- + +feat: when WorkOS is enabled, use a redirect-based workflow for authentication diff --git a/packages/@tinacms/schema-tools/src/types/index.ts b/packages/@tinacms/schema-tools/src/types/index.ts index 9b1ba4de76..e73a5706b8 100644 --- a/packages/@tinacms/schema-tools/src/types/index.ts +++ b/packages/@tinacms/schema-tools/src/types/index.ts @@ -583,7 +583,7 @@ export type Template = { fields: Field[]; } & MaybeNamespace; -type TokenObject = { +export type TokenObject = { id_token?: string; access_token?: string; refresh_token?: string; diff --git a/packages/tinacms/src/auth/AuthModal.tsx b/packages/tinacms/src/auth/AuthModal.tsx index e6f7584273..9b11423230 100644 --- a/packages/tinacms/src/auth/AuthModal.tsx +++ b/packages/tinacms/src/auth/AuthModal.tsx @@ -15,6 +15,7 @@ interface ModalBuilderProps { actions: ButtonProps[]; close(): void; children?: React.ReactNode; + busy?: boolean; } export function ModalBuilder(modalProps: ModalBuilderProps) { @@ -34,7 +35,7 @@ export function ModalBuilder(modalProps: ModalBuilderProps) { {modalProps.actions.map((action) => ( - + ))} @@ -50,9 +51,10 @@ interface ButtonProps { name: string; action(): Promise; primary: boolean; + busy?: boolean; } -export const AsyncButton = ({ name, primary, action }: ButtonProps) => { +export const AsyncButton = ({ name, primary, action, busy }: ButtonProps) => { const [submitting, setSubmitting] = useState(false); const [mounted, setMounted] = useState(false); @@ -62,7 +64,7 @@ export const AsyncButton = ({ name, primary, action }: ButtonProps) => { }, []); const onClick = useCallback(async () => { - if (!mounted) return; + if (!mounted || busy) return; setSubmitting(true); try { await action(); @@ -71,15 +73,17 @@ export const AsyncButton = ({ name, primary, action }: ButtonProps) => { setSubmitting(false); throw e; } - }, [action, setSubmitting, mounted]); + }, [action, setSubmitting, mounted, busy]); + + const isBusy = busy || submitting; return ( diff --git a/packages/tinacms/src/auth/TinaCloudProvider.tsx b/packages/tinacms/src/auth/TinaCloudProvider.tsx index fc126464c2..30b5a01579 100644 --- a/packages/tinacms/src/auth/TinaCloudProvider.tsx +++ b/packages/tinacms/src/auth/TinaCloudProvider.tsx @@ -14,7 +14,6 @@ import { } from '@tinacms/toolkit'; import React, { useEffect, useState } from 'react'; import { ModalBuilder } from './AuthModal'; -import { AuthenticationCancelledError } from './authenticate'; import loginLlama from './tina-login.png'; import { TinaAdminApi } from '../admin/api'; @@ -22,12 +21,17 @@ import { Client, LocalSearchClient, TinaCMSSearchClient, + TinaCloudAuthProvider, TinaIOConfig, } from '../internalClient'; -import { CreateClientProps, createClient } from '../utils'; -import { useTinaAuthRedirect } from './useTinaAuthRedirect'; -import { captureEvent } from '../lib/posthog/posthogProvider'; import { BranchSwitchedEvent } from '../lib/posthog/posthog'; +import { captureEvent } from '../lib/posthog/posthogProvider'; +import { CreateClientProps, createClient } from '../utils'; +import { AuthenticationCancelledError } from './authenticate'; +import { + type AuthRedirectParams, + useTinaAuthRedirect, +} from './useTinaAuthRedirect'; type ModalNames = null | 'authenticate' | 'error'; @@ -54,7 +58,8 @@ const AuthWallInner = ({ children, cms, getModalActions, -}: TinaCloudAuthWallProps) => { + isAuthRedirect, +}: TinaCloudAuthWallProps & { isAuthRedirect?: boolean }) => { const client: Client = cms.api.tina; // Whether we are using TinaCloud for auth const isTinaCloud = @@ -81,6 +86,14 @@ const AuthWallInner = ({ React.useEffect(() => { let mounted = true; + + if (isAuthRedirect) { + setActiveModal('authenticate'); + return () => { + mounted = false; + }; + } + client.authProvider .isAuthenticated() .then((isAuthenticated) => { @@ -156,15 +169,12 @@ const AuthWallInner = ({ } return onAuthenticated(); } catch (e: any) { - // If user just closed the popup, silently reset - don't show error - // Check both instanceof and error name (in case of module boundary issues) if ( e instanceof AuthenticationCancelledError || e?.name === 'AuthenticationCancelledError' ) { return; } - console.error(e); setActiveModal('error'); setErrorMessage({ @@ -213,6 +223,7 @@ const AuthWallInner = ({ ) } close={close} + busy={isAuthRedirect} actions={[ ...otherModalActions, { @@ -229,6 +240,7 @@ const AuthWallInner = ({ title={modalTitle} message={''} close={close} + busy={isAuthRedirect} actions={[ ...otherModalActions, { @@ -345,7 +357,7 @@ export const TinaCloudProvider = ( 'tinacms-current-branch', baseBranch ); - useTinaAuthRedirect(); + const cms = React.useMemo( () => props.cms || @@ -442,6 +454,17 @@ export const TinaCloudProvider = ( const isTinaCloud = !client.isLocalMode && !client.schema?.config?.config?.contentApiUrlOverride; + const isTinaCloudAuth = client.authProvider instanceof TinaCloudAuthProvider; + + const urlParams = new URLSearchParams(window.location.search); + const authRedirectParams: AuthRedirectParams = { + code: urlParams.get('code'), + state: urlParams.get('state'), + error: urlParams.get('error'), + }; + const isAuthRedirect = + isTinaCloudAuth && !!(authRedirectParams.code && authRedirectParams.state); + useTinaAuthRedirect(authRedirectParams, isTinaCloudAuth); const SessionProvider = client.authProvider.getSessionProvider(); const handleListBranches = async (): Promise => { @@ -537,7 +560,7 @@ export const TinaCloudProvider = ( > - + diff --git a/packages/tinacms/src/auth/authenticate.test.ts b/packages/tinacms/src/auth/authenticate.test.ts index bbda9d0c61..87b678085b 100644 --- a/packages/tinacms/src/auth/authenticate.test.ts +++ b/packages/tinacms/src/auth/authenticate.test.ts @@ -3,6 +3,9 @@ import { authenticate, AUTH_TOKEN_KEY, AuthenticationCancelledError, + getWorkosEnabled, + resetWorkosEnabledCache, + PKCE_STORAGE_KEY, } from './authenticate'; vi.mock('./popupWindow', () => ({ @@ -11,6 +14,8 @@ vi.mock('./popupWindow', () => ({ import popupWindow from './popupWindow'; +const CLIENT_ID = 'test-client-id'; +const IDENTITY_API_URL = 'https://api.example'; const FRONTEND_URL = 'https://frontend.example'; const EXPECTED_ORIGIN = 'https://frontend.example'; const UNTRUSTED_ORIGIN = 'https://untrusted.example'; @@ -24,14 +29,11 @@ const validData = { refresh_token: 'refresh-token', }; -// A stand-in for the Window object returned by window.open. const makeAuthTab = () => ({ close: vi.fn(), closed: false, }); -// Captured `message` listeners registered against the real window. We spy on -// addEventListener/removeEventListener so the real window object is preserved. let messageListeners: Array<(e: MessageEvent) => void>; let authTab: ReturnType; @@ -41,7 +43,17 @@ const dispatch = (e: Partial) => { } }; +let originalHref: string; +let fetchSpy: ReturnType; + beforeEach(() => { + originalHref = window.location.href; + Object.defineProperty(window, 'location', { + value: new URL('https://mysite.com/admin'), + writable: true, + }); + localStorage.clear(); + vi.useFakeTimers(); messageListeners = []; authTab = makeAuthTab(); @@ -62,15 +74,24 @@ beforeEach(() => { } } ); + + resetWorkosEnabledCache(); + fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); }); afterEach(() => { + Object.defineProperty(window, 'location', { + value: new URL(originalHref), + writable: true, + }); + localStorage.clear(); vi.useRealTimers(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); -// Drive the popup-closed poll so a pending authenticate() promise settles -// deterministically, and assert it rejects with the cancellation error. +// Set up rejection handler before advancing timers to avoid unhandled rejection warning. const settleViaPopupClose = async (result: Promise) => { const expectation = expect(result).rejects.toThrowError( new AuthenticationCancelledError('Popup was closed') @@ -80,72 +101,142 @@ const settleViaPopupClose = async (result: Promise) => { await expectation; }; -describe('authenticate origin/source validation', () => { - it('ignores a message from an untrusted origin', async () => { - const result = authenticate('client-id', FRONTEND_URL); +describe('getWorkosEnabled', () => { + it('returns true when workosEnabled is true', async () => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ workosEnabled: true }), + }); - dispatch({ - origin: UNTRUSTED_ORIGIN, - source: authTab as unknown as Window, - data: validData, + const result = await getWorkosEnabled(IDENTITY_API_URL); + expect(result).toBe(true); + expect(fetchSpy).toHaveBeenCalledWith(`${IDENTITY_API_URL}/v2/auth/config`); + }); + + it('returns false when workosEnabled is false', async () => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ workosEnabled: false }), }); - // The handler should not have resolved; the listener stays registered. - expect(messageListeners.length).toBe(1); - expect(authTab.close).not.toHaveBeenCalled(); + const result = await getWorkosEnabled(IDENTITY_API_URL); + expect(result).toBe(false); + }); - await settleViaPopupClose(result); + it('caches the result across calls', async () => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ workosEnabled: true }), + }); + + await getWorkosEnabled(IDENTITY_API_URL); + await getWorkosEnabled(IDENTITY_API_URL); + + expect(fetchSpy).toHaveBeenCalledTimes(1); }); - it('ignores a message from the expected origin but a different source', async () => { - const result = authenticate('client-id', FRONTEND_URL); + it('throws on non-200 response', async () => { + fetchSpy.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + }); - const otherWindow = makeAuthTab(); - dispatch({ - origin: EXPECTED_ORIGIN, - source: otherWindow as unknown as Window, - data: validData, + await expect(getWorkosEnabled(IDENTITY_API_URL)).rejects.toThrow( + 'Failed to fetch auth config: 500 Internal Server Error' + ); + }); + + it('throws when workosEnabled is missing from response', async () => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), }); - expect(messageListeners.length).toBe(1); - expect(authTab.close).not.toHaveBeenCalled(); + await expect(getWorkosEnabled(IDENTITY_API_URL)).rejects.toThrow( + 'Invalid auth config response' + ); + }); +}); - await settleViaPopupClose(result); +describe('authenticate — WorkOS enabled (PKCE redirect)', () => { + beforeEach(() => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ workosEnabled: true }), + }); }); - it('accepts a message from the expected origin and the exact opened popup source', async () => { - const result = authenticate('client-id', FRONTEND_URL); + it('stores PKCE data in localStorage', async () => { + authenticate(CLIENT_ID, IDENTITY_API_URL, FRONTEND_URL); - dispatch({ - origin: EXPECTED_ORIGIN, - source: authTab as unknown as Window, - data: validData, + await vi.waitFor(() => { + const stored = JSON.parse(localStorage.getItem(PKCE_STORAGE_KEY)); + expect(stored).toBeTruthy(); + expect(stored.client_id).toBe(CLIENT_ID); + expect(stored.identity_api_url).toBe(IDENTITY_API_URL); + expect(stored.code_verifier).toHaveLength(128); + expect(stored.state).toBeTruthy(); }); + }); - await expect(result).resolves.toEqual({ - id_token: 'id-token', - access_token: 'access-token', - refresh_token: 'refresh-token', + it('redirects to /v2/auth/tinacms', async () => { + await authenticate(CLIENT_ID, IDENTITY_API_URL, FRONTEND_URL); + + const redirectUrl = new URL(window.location.href); + expect(redirectUrl.origin + redirectUrl.pathname).toBe( + `${IDENTITY_API_URL}/v2/auth/tinacms` + ); + expect(redirectUrl.searchParams.get('response_type')).toBe('code'); + expect(redirectUrl.searchParams.get('client_id')).toBe(CLIENT_ID); + expect(redirectUrl.searchParams.get('redirect_uri')).toBe( + 'https://mysite.com/admin' + ); + expect(redirectUrl.searchParams.get('code_challenge_method')).toBe('S256'); + expect(redirectUrl.searchParams.get('code_challenge')).toBeTruthy(); + expect(redirectUrl.searchParams.get('state')).toBeTruthy(); + }); +}); + +describe('authenticate — WorkOS disabled (popup)', () => { + beforeEach(() => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ workosEnabled: false }), }); - expect(authTab.close).toHaveBeenCalled(); - // Listener cleaned up after a successful login. - expect(messageListeners.length).toBe(0); }); - it('derives expectedOrigin from new URL(frontendUrl).origin, including the port', async () => { - const result = authenticate('client-id', 'https://frontend.example:8443'); + it('opens a popup to the frontend signin URL', async () => { + const result = authenticate(CLIENT_ID, IDENTITY_API_URL, FRONTEND_URL); + + await vi.waitFor(() => { + expect(popupWindow).toHaveBeenCalledWith( + expect.stringContaining(`${FRONTEND_URL}/signin`), + '_blank', + window, + 1000, + 700 + ); + }); - // Same host but the default port (no :8443) is a different origin. - dispatch({ - origin: 'https://frontend.example', - source: authTab as unknown as Window, - data: validData, + // Set up rejection handler before triggering to avoid unhandled rejection warning. + const expectation = expect(result).rejects.toThrow( + AuthenticationCancelledError + ); + authTab.closed = true; + await vi.advanceTimersByTimeAsync(600); + await expectation; + }); + + it('resolves with tokens on valid message', async () => { + const result = authenticate(CLIENT_ID, IDENTITY_API_URL, FRONTEND_URL); + + await vi.waitFor(() => { + expect(messageListeners.length).toBe(1); }); - expect(authTab.close).not.toHaveBeenCalled(); - // Exact origin including the port is accepted. dispatch({ - origin: 'https://frontend.example:8443', + origin: EXPECTED_ORIGIN, source: authTab as unknown as Window, data: validData, }); @@ -158,30 +249,33 @@ describe('authenticate origin/source validation', () => { expect(authTab.close).toHaveBeenCalled(); }); - it('does not read event.data before origin/source validation', async () => { - const result = authenticate('client-id', FRONTEND_URL); + it('rejects when popup is closed without auth', async () => { + const result = authenticate(CLIENT_ID, IDENTITY_API_URL, FRONTEND_URL); - let dataAccessed = false; - const trap = { - get source() { - dataAccessed = true; - return TINA_LOGIN_EVENT; - }, - }; + await settleViaPopupClose(result); + }); +}); - // Untrusted origin: the handler must bail out before touching e.data. - dispatch({ - origin: UNTRUSTED_ORIGIN, - source: authTab as unknown as Window, - data: trap, +describe('authenticate — propagates fetch errors', () => { + it('throws when /v2/auth/config fetch fails', async () => { + fetchSpy.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', }); - expect(dataAccessed).toBe(false); - - await settleViaPopupClose(result); + await expect( + authenticate(CLIENT_ID, IDENTITY_API_URL, FRONTEND_URL) + ).rejects.toThrow('Failed to fetch auth config'); }); }); -it('exports the auth token key', () => { - expect(AUTH_TOKEN_KEY).toBe('tinacms-auth'); +describe('exports', () => { + it('exports the auth token key', () => { + expect(AUTH_TOKEN_KEY).toBe('tinacms-auth'); + }); + + it('exports the PKCE storage key', () => { + expect(PKCE_STORAGE_KEY).toBe('tinacms-pkce'); + }); }); diff --git a/packages/tinacms/src/auth/authenticate.ts b/packages/tinacms/src/auth/authenticate.ts index 93311ef286..97d7c007c2 100644 --- a/packages/tinacms/src/auth/authenticate.ts +++ b/packages/tinacms/src/auth/authenticate.ts @@ -1,19 +1,12 @@ -/** - -*/ - +import { TokenObject } from '@tinacms/schema-tools'; import popupWindow from './popupWindow'; +import { randomString, generateCodeChallenge } from './pkce'; -const TINA_LOGIN_EVENT = 'tinaCloudLogin'; export const AUTH_TOKEN_KEY = 'tinacms-auth'; +export const PKCE_STORAGE_KEY = 'tinacms-pkce'; -export type TokenObject = { - id_token?: string; - access_token?: string; - refresh_token?: string; -}; +const TINA_LOGIN_EVENT = 'tinaCloudLogin'; -// Custom error for when user cancels authentication by closing the popup export class AuthenticationCancelledError extends Error { constructor(message = 'Authentication cancelled') { super(message); @@ -21,19 +14,44 @@ export class AuthenticationCancelledError extends Error { } } -export const authenticate = ( +let workosEnabledPromise: Promise | null = null; + +export async function getWorkosEnabled( + identityApiUrl: string +): Promise { + if (!workosEnabledPromise) { + workosEnabledPromise = (async () => { + const res = await fetch(`${identityApiUrl}/v2/auth/config`); + if (!res.ok) { + throw new Error( + `Failed to fetch auth config: ${res.status} ${res.statusText}` + ); + } + + const data: { workosEnabled?: boolean } = await res.json(); + if (typeof data.workosEnabled !== 'boolean') { + throw new Error('Invalid auth config response'); + } + + return data.workosEnabled; + })(); + } + + return workosEnabledPromise; +} + +export function resetWorkosEnabledCache(): void { + workosEnabledPromise = null; +} + +export function authenticatePopup( clientId: string, frontendUrl: string -): Promise => { +): Promise { return new Promise((resolve, reject) => { const origin = `${window.location.protocol}//${window.location.host}`; - - // The origin we expect login results to be posted from. Only messages - // sent from this origin are trusted. const expectedOrigin = new URL(frontendUrl).origin; - // The exact Window we opened. Only messages whose source is this Window - // are trusted. const authTab = popupWindow( `${frontendUrl}/signin?clientId=${clientId}&origin=${origin}`, '_blank', @@ -42,7 +60,6 @@ export const authenticate = ( 700 ); - // Check if popup was blocked if (!authTab) { reject( new Error( @@ -57,10 +74,7 @@ export const authenticate = ( window.removeEventListener('message', messageHandler); }; - // Message handler for auth completion const messageHandler = (e: MessageEvent) => { - // Validate the message origin and source before reading or trusting - // anything in e.data. if (e.origin !== expectedOrigin || e.source !== authTab) { return; } @@ -79,7 +93,6 @@ export const authenticate = ( } }; - // Poll to detect if popup was closed without completing auth const pollInterval = setInterval(() => { if (authTab.closed) { cleanup(); @@ -89,4 +102,49 @@ export const authenticate = ( window.addEventListener('message', messageHandler); }); -}; +} + +export async function authenticatePKCE( + clientId: string, + identityApiUrl: string +): Promise { + const codeVerifier = randomString(128); + const codeChallenge = await generateCodeChallenge(codeVerifier); + const state = randomString(); + + localStorage.setItem( + PKCE_STORAGE_KEY, + JSON.stringify({ + code_verifier: codeVerifier, + state, + client_id: clientId, + identity_api_url: identityApiUrl, + }) + ); + + const redirectUri = window.location.origin + window.location.pathname; + + const params = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: redirectUri, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state, + }); + + window.location.href = `${identityApiUrl}/v2/auth/tinacms?${params.toString()}`; +} + +export async function authenticate( + clientId: string, + identityApiUrl: string, + frontendUrl: string +): Promise { + const workosEnabled = await getWorkosEnabled(identityApiUrl); + if (workosEnabled) { + await authenticatePKCE(clientId, identityApiUrl); + } else { + return authenticatePopup(clientId, frontendUrl); + } +} diff --git a/packages/tinacms/src/auth/pkce.test.ts b/packages/tinacms/src/auth/pkce.test.ts new file mode 100644 index 0000000000..a78c6af74b --- /dev/null +++ b/packages/tinacms/src/auth/pkce.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { base64UrlEncode, generateCodeChallenge, randomString } from './pkce'; + +const ALLOWED_CHARS = /^[A-Za-z0-9]+$/; + +describe('randomString', () => { + it('defaults to a length of 40', () => { + expect(randomString()).toHaveLength(40); + }); + + it('honors a custom length', () => { + expect(randomString(128)).toHaveLength(128); + expect(randomString(8)).toHaveLength(8); + }); + + it('only uses URL-safe characters', () => { + expect(randomString(100)).toMatch(ALLOWED_CHARS); + }); + + it('produces different values across calls', () => { + const first = randomString(); + const second = randomString(); + expect(first).not.toBe(second); + }); +}); + +describe('base64UrlEncode', () => { + it('produces URL-safe output without padding', () => { + const bytes = new Uint8Array([0xfb, 0xff, 0xbf]); + expect(base64UrlEncode(bytes.buffer)).toBe('-_-_'); + }); + + it('is deterministic for the same input', () => { + const bytes = new Uint8Array([0x12, 0x34, 0x56]).buffer; + expect(base64UrlEncode(bytes)).toBe(base64UrlEncode(bytes)); + }); +}); + +describe('generateCodeChallenge', () => { + // RFC 7636 Appendix B test vector. + it('matches the RFC 7636 known challenge', async () => { + const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; + await expect(generateCodeChallenge(verifier)).resolves.toBe( + 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' + ); + }); + + it('produces URL-safe output without padding', async () => { + const challenge = await generateCodeChallenge('some-verifier'); + expect(challenge).not.toContain('+'); + expect(challenge).not.toContain('/'); + expect(challenge).not.toContain('='); + expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('is deterministic for the same verifier', async () => { + const first = await generateCodeChallenge('some-verifier'); + const second = await generateCodeChallenge('some-verifier'); + expect(first).toBe(second); + }); +}); diff --git a/packages/tinacms/src/auth/pkce.ts b/packages/tinacms/src/auth/pkce.ts new file mode 100644 index 0000000000..b162a8db16 --- /dev/null +++ b/packages/tinacms/src/auth/pkce.ts @@ -0,0 +1,25 @@ +export function randomString(length: number = 40): string { + const possible = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const randomValues = new Uint8Array(length); + crypto.getRandomValues(randomValues); + return Array.from(randomValues, (byte) => + possible.charAt(byte % possible.length) + ).join(''); +} + +export function base64UrlEncode(arrayBuffer: ArrayBuffer): string { + const bytes = new Uint8Array(arrayBuffer); + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +export async function generateCodeChallenge(verifier: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const digest = await crypto.subtle.digest('SHA-256', data); + return base64UrlEncode(digest); +} diff --git a/packages/tinacms/src/auth/useGenerator.ts b/packages/tinacms/src/auth/useGenerator.ts deleted file mode 100644 index 6345a115ab..0000000000 --- a/packages/tinacms/src/auth/useGenerator.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - -*/ - -import * as crypto from 'crypto-js'; - -const randomString = (length: number = 40) => { - let state = ''; - const possible = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - for (let i = 0; i < length; i++) - state += possible.charAt(Math.floor(Math.random() * possible.length)); - return state; -}; - -function generateCodeVerifier() { - return randomString(128); -} - -function base64URL(string) { - crypto.enc.Base64.stringify(string); - return string - .toString(crypto.enc.Base64) - .replace(/=/g, '') - .replace(/\+/g, '-') - .replace(/\//g, '_'); -} - -function generateCodeChallenge(code_verifier) { - return base64URL(crypto.SHA256(code_verifier)); -} - -export const useGenerator = () => { - const codeVerifier = generateCodeVerifier(); - return { - state: randomString(), - codeChallenge: generateCodeChallenge(codeVerifier), - codeVerifier, - }; -}; diff --git a/packages/tinacms/src/auth/useTinaAuthRedirect.test.tsx b/packages/tinacms/src/auth/useTinaAuthRedirect.test.tsx new file mode 100644 index 0000000000..fc349cd31f --- /dev/null +++ b/packages/tinacms/src/auth/useTinaAuthRedirect.test.tsx @@ -0,0 +1,270 @@ +import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AUTH_TOKEN_KEY, PKCE_STORAGE_KEY } from './authenticate'; +import { useTinaAuthRedirect } from './useTinaAuthRedirect'; + +const CODE = 'auth-code'; +const STATE = 'expected-state'; +const CODE_VERIFIER = 'test-verifier'; +const CLIENT_ID = 'test-client-id'; +const IDENTITY_API_URL = 'https://api.example.com'; +const REDIRECT_URI = window.location.origin + window.location.pathname; + +const pkceData = { + code_verifier: CODE_VERIFIER, + state: STATE, + client_id: CLIENT_ID, + identity_api_url: IDENTITY_API_URL, +}; + +const seedPkceStorage = (overrides: Partial = {}) => { + localStorage.setItem( + PKCE_STORAGE_KEY, + JSON.stringify({ ...pkceData, ...overrides }) + ); +}; + +let fetchSpy: ReturnType; +let consoleErrorSpy: ReturnType; +let replaceStateSpy: ReturnType; + +beforeEach(() => { + localStorage.clear(); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + replaceStateSpy = vi + .spyOn(window.history, 'replaceState') + .mockImplementation(() => {}); + vi.spyOn(window.location, 'reload').mockImplementation(() => {}); + fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + localStorage.clear(); +}); + +describe('useTinaAuthRedirect', () => { + it('reports auth errors and bails without touching storage', () => { + renderHook(() => + useTinaAuthRedirect({ + code: CODE, + state: STATE, + error: 'access_denied', + }) + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Auth error:', + 'access_denied' + ); + expect(replaceStateSpy).toHaveBeenCalledWith( + {}, + '', + window.location.pathname + ); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(localStorage.getItem(PKCE_STORAGE_KEY)).toBeNull(); + }); + + it('does nothing when code is missing', () => { + renderHook(() => + useTinaAuthRedirect({ code: null, state: STATE, error: null }) + ); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(replaceStateSpy).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('does nothing when state is missing', () => { + renderHook(() => + useTinaAuthRedirect({ code: CODE, state: null, error: null }) + ); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(replaceStateSpy).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('does nothing when disabled even with code and state', () => { + renderHook(() => + useTinaAuthRedirect({ code: CODE, state: STATE, error: null }, false) + ); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(replaceStateSpy).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(localStorage.getItem(PKCE_STORAGE_KEY)).toBeNull(); + }); + + it('bails when no PKCE data is stored', () => { + renderHook(() => + useTinaAuthRedirect({ code: CODE, state: STATE, error: null }) + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'No PKCE data found in localStorage' + ); + expect(replaceStateSpy).toHaveBeenCalledWith( + {}, + '', + window.location.pathname + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('rejects a state mismatch and clears the stored PKCE data', () => { + seedPkceStorage(); + + renderHook(() => + useTinaAuthRedirect({ + code: CODE, + state: 'attacker-controlled-state', + error: null, + }) + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'State mismatch - possible CSRF attack' + ); + expect(localStorage.getItem(PKCE_STORAGE_KEY)).toBeNull(); + expect(replaceStateSpy).toHaveBeenCalledWith( + {}, + '', + window.location.pathname + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('exchanges the code and stores the tokens on success', async () => { + seedPkceStorage(); + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ access_token: 'access-token', refresh_token: '' }), + }); + + renderHook(() => + useTinaAuthRedirect({ code: CODE, state: STATE, error: null }) + ); + + await vi.waitFor(() => { + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + expect(fetchSpy).toHaveBeenCalledWith( + `${IDENTITY_API_URL}/oauth/token`, + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }) + ); + + const body = fetchSpy.mock.calls[0][1].body as string; + expect(new URLSearchParams(body).toString()).toBe( + new URLSearchParams({ + grant_type: 'authorization_code', + code: CODE, + redirect_uri: REDIRECT_URI, + client_id: CLIENT_ID, + code_verifier: CODE_VERIFIER, + }).toString() + ); + + await vi.waitFor(() => { + expect(localStorage.getItem(AUTH_TOKEN_KEY)).toBe( + JSON.stringify({ access_token: 'access-token', refresh_token: '' }) + ); + }); + expect(localStorage.getItem(PKCE_STORAGE_KEY)).toBeNull(); + expect(replaceStateSpy).toHaveBeenCalledWith( + {}, + '', + window.location.pathname + ); + expect(window.location.reload).toHaveBeenCalled(); + }); + + it('bails and clears storage when the token endpoint returns an error', async () => { + seedPkceStorage(); + fetchSpy.mockResolvedValueOnce({ + ok: false, + status: 400, + json: () => Promise.resolve({ message: 'invalid_grant' }), + }); + + renderHook(() => + useTinaAuthRedirect({ code: CODE, state: STATE, error: null }) + ); + + await vi.waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Token exchange failed:', + expect.any(Error) + ); + }); + + expect(localStorage.getItem(PKCE_STORAGE_KEY)).toBeNull(); + expect(replaceStateSpy).toHaveBeenCalledWith( + {}, + '', + window.location.pathname + ); + expect(localStorage.getItem(AUTH_TOKEN_KEY)).toBeNull(); + }); + + it('bails and clears storage when the response is ok but body contains an error', async () => { + seedPkceStorage(); + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ error: 'invalid_grant' }), + }); + + renderHook(() => + useTinaAuthRedirect({ code: CODE, state: STATE, error: null }) + ); + + await vi.waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Token exchange failed:', + expect.any(Error) + ); + }); + + expect(localStorage.getItem(PKCE_STORAGE_KEY)).toBeNull(); + expect(replaceStateSpy).toHaveBeenCalledWith( + {}, + '', + window.location.pathname + ); + expect(localStorage.getItem(AUTH_TOKEN_KEY)).toBeNull(); + }); + + it('bails and clears storage when the token fetch fails', async () => { + seedPkceStorage(); + fetchSpy.mockRejectedValueOnce(new Error('network down')); + + renderHook(() => + useTinaAuthRedirect({ code: CODE, state: STATE, error: null }) + ); + + await vi.waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Token exchange failed:', + new Error('network down') + ); + }); + + expect(localStorage.getItem(PKCE_STORAGE_KEY)).toBeNull(); + expect(replaceStateSpy).toHaveBeenCalledWith( + {}, + '', + window.location.pathname + ); + expect(localStorage.getItem(AUTH_TOKEN_KEY)).toBeNull(); + }); +}); diff --git a/packages/tinacms/src/auth/useTinaAuthRedirect.tsx b/packages/tinacms/src/auth/useTinaAuthRedirect.tsx index 6c46e4330c..7091386e16 100644 --- a/packages/tinacms/src/auth/useTinaAuthRedirect.tsx +++ b/packages/tinacms/src/auth/useTinaAuthRedirect.tsx @@ -1,24 +1,96 @@ -/** +import { useEffect } from 'react'; +import { AUTH_TOKEN_KEY, PKCE_STORAGE_KEY } from './authenticate'; -*/ +export interface AuthRedirectParams { + code: string | null; + state: string | null; + error: string | null; +} -import { useEffect } from 'react'; +export const useTinaAuthRedirect = ( + params: AuthRedirectParams, + enabled = true +) => { + const { code, state, error } = params; -const TINA_AUTH_CONFIG = 'tina_auth_config'; -export const useTinaAuthRedirect = () => { useEffect(() => { - const urlParams = new URLSearchParams(window.location.search); + if (!enabled) { + return; + } - const config = { - code: urlParams.get('code') || '', - scope: urlParams.get('scope') || 'email', - state: urlParams.get('state'), - }; + if (error) { + console.error('Auth error:', error); + window.history.replaceState({}, '', window.location.pathname); + return; + } - if (!config.code) { + if (!code || !state) { return; } - localStorage[TINA_AUTH_CONFIG] = JSON.stringify(config); - }, []); + const pkceData = JSON.parse( + localStorage.getItem(PKCE_STORAGE_KEY) || 'null' + ); + if (!pkceData) { + console.error('No PKCE data found in localStorage'); + window.history.replaceState({}, '', window.location.pathname); + return; + } + + if (pkceData.state !== state) { + console.error('State mismatch - possible CSRF attack'); + localStorage.removeItem(PKCE_STORAGE_KEY); + window.history.replaceState({}, '', window.location.pathname); + return; + } + + const { code_verifier, client_id, identity_api_url } = pkceData; + const redirectUri = window.location.origin + window.location.pathname; + + const tokenParams = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id, + code_verifier, + }); + + fetch(`${identity_api_url}/oauth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: tokenParams.toString(), + }) + .then(async (res) => { + const data = await res.json(); + if (!res.ok) { + throw new Error( + data.message || data.error || `Token exchange failed: ${res.status}` + ); + } + if (data.error) { + throw new Error(data.error); + } + return data; + }) + .then((data) => { + localStorage.setItem( + AUTH_TOKEN_KEY, + JSON.stringify({ + access_token: data.access_token, + refresh_token: data.refresh_token, + }) + ); + + localStorage.removeItem(PKCE_STORAGE_KEY); + window.history.replaceState({}, '', window.location.pathname); + window.location.reload(); + }) + .catch((err) => { + console.error('Token exchange failed:', err); + localStorage.removeItem(PKCE_STORAGE_KEY); + window.history.replaceState({}, '', window.location.pathname); + }); + }, [code, state, error, enabled]); }; diff --git a/packages/tinacms/src/internalClient/authProvider.test.ts b/packages/tinacms/src/internalClient/authProvider.test.ts index ddadd92438..860ad689e9 100644 --- a/packages/tinacms/src/internalClient/authProvider.test.ts +++ b/packages/tinacms/src/internalClient/authProvider.test.ts @@ -43,7 +43,7 @@ describe('TinaCloudAuthProvider getRefreshedToken', () => { }; const storedToken = (provider: TinaCloudAuthProvider): TokenObject => - JSON.parse(provider.token); + provider.token; it('keeps the existing refresh token when the response does not include a new one', async () => { const provider = buildProvider(); diff --git a/packages/tinacms/src/internalClient/authProvider.ts b/packages/tinacms/src/internalClient/authProvider.ts index 2b6899cd52..7a9088ce1f 100644 --- a/packages/tinacms/src/internalClient/authProvider.ts +++ b/packages/tinacms/src/internalClient/authProvider.ts @@ -1,9 +1,9 @@ -import { AuthProvider, LoginStrategy } from '@tinacms/schema-tools'; import { - authenticate, - AUTH_TOKEN_KEY, + AuthProvider, + LoginStrategy, TokenObject, -} from '../auth/authenticate'; +} from '@tinacms/schema-tools'; +import { authenticate, AUTH_TOKEN_KEY } from '../auth/authenticate'; import DefaultSessionProvider from '../auth/defaultSessionProvider'; type Input = Parameters[0]; @@ -23,7 +23,7 @@ export abstract class AbstractAuthProvider implements AuthProvider { async fetchWithToken(input: Input, init: Init): FetchReturn { const headers = init?.headers || {}; const token = await this.getToken(); - const accessToken = token?.id_token ?? token?.access_token; + const accessToken = token?.access_token ?? token?.id_token; if (accessToken) { headers['Authorization'] = 'Bearer ' + accessToken; } @@ -71,7 +71,7 @@ export class TinaCloudAuthProvider extends AbstractAuthProvider { clientId: string; identityApiUrl: string; frontendUrl: string; - token: string; // used with memory storage + token: TokenObject; // used with memory storage setToken: (_token: TokenObject | null) => void; getToken: () => Promise; @@ -115,8 +115,7 @@ export class TinaCloudAuthProvider extends AbstractAuthProvider { case 'MEMORY': this.getToken = async () => { if (this.token) { - const tokens = JSON.parse(this.token); - return await this.getRefreshedToken(tokens); + return await this.getRefreshedToken(this.token); } else { return { access_token: null, @@ -125,8 +124,8 @@ export class TinaCloudAuthProvider extends AbstractAuthProvider { }; } }; - this.setToken = (token) => { - this.token = JSON.stringify(token, null, 2); + this.setToken = (token: TokenObject) => { + this.token = token; }; break; case 'CUSTOM': @@ -140,9 +139,15 @@ export class TinaCloudAuthProvider extends AbstractAuthProvider { } } async authenticate() { - const token = await authenticate(this.clientId, this.frontendUrl); - this.setToken(token); - return token; + const result = await authenticate( + this.clientId, + this.identityApiUrl, + this.frontendUrl + ); + if (result) { + this.setToken(result); + return result; + } } async getUser() { if (!this.clientId) { @@ -170,16 +175,12 @@ export class TinaCloudAuthProvider extends AbstractAuthProvider { this.setToken(null); } - async getRefreshedToken(tokens: { - access_token?: string; - id_token?: string; - refresh_token?: string; - }): Promise { + async getRefreshedToken(tokens: TokenObject): Promise { const { access_token, id_token, refresh_token } = tokens; if (!access_token) { throw new Error('Unable to refresh auth tokens: missing access_token'); } - const { client_id, exp } = this.parseJwt(access_token); + const { exp } = this.parseJwt(access_token); // if the token is going to expire within the next two minutes, refresh it now if (Date.now() / 1000 >= exp - 120) { @@ -188,7 +189,7 @@ export class TinaCloudAuthProvider extends AbstractAuthProvider { const params = new URLSearchParams(); params.set('grant_type', 'refresh_token'); params.set('refresh_token', refresh_token); - params.set('client_id', client_id); + params.set('client_id', this.clientId); try { const res = await fetch(url, { @@ -250,7 +251,7 @@ export class LocalAuthProvider extends AbstractAuthProvider { return localStorage.getItem(LOCAL_CLIENT_KEY) === 'true'; } async getToken() { - return Promise.resolve({ id_token: '' }); + return Promise.resolve({ access_token: 'LOCAL', refresh_token: 'LOCAL' }); } async logout() { localStorage.removeItem(LOCAL_CLIENT_KEY); diff --git a/packages/tinacms/src/internalClient/index.test.ts b/packages/tinacms/src/internalClient/index.test.ts index ded573f37b..d19485415f 100644 --- a/packages/tinacms/src/internalClient/index.test.ts +++ b/packages/tinacms/src/internalClient/index.test.ts @@ -84,7 +84,7 @@ describe('Tina Client', () => { expect(client.authProvider).toBeInstanceOf(LocalAuthProvider); }); - it('sends no Authorization header because LocalAuthProvider returns an empty id_token', async () => { + it('sends Authorization header with LOCAL token from LocalAuthProvider', async () => { const fetchMock = stubFetchOnce( makeResponse({ status: 200, body: { data: {} } }) ); @@ -92,7 +92,7 @@ describe('Tina Client', () => { await client.request('{ x }', { variables: {} }); const [, init] = fetchMock.mock.calls[0]; - expect(init.headers).not.toHaveProperty('Authorization'); + expect(init.headers).toHaveProperty('Authorization', 'Bearer LOCAL'); }); }); diff --git a/packages/tinacms/src/internalClient/index.ts b/packages/tinacms/src/internalClient/index.ts index a13da1c4f8..eab61a4b6f 100644 --- a/packages/tinacms/src/internalClient/index.ts +++ b/packages/tinacms/src/internalClient/index.ts @@ -10,13 +10,13 @@ import { parse, print, } from 'graphql'; -import { TokenObject } from '../auth/authenticate'; import { ASYNC_POLLER_ERROR, AuthProvider, Schema, TinaSchema, + TokenObject, addNamespaceToSchema, } from '@tinacms/schema-tools'; import { From 2860f569b3f4f8f6115ee4399af855ea3baa61e1 Mon Sep 17 00:00:00 2001 From: "Matt Wicks [SSW]" Date: Mon, 17 Aug 2026 16:27:33 +1000 Subject: [PATCH 6/6] fix(@tinacms/mdx): keep bold and italic when the selection starts or ends with a space (#7427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #7426 Demo - https://youtu.be/OX4HWD5bhmc ## What was broken Select a word *and its trailing space*, then hit bold. TinaCMS saved `**word **`. CommonMark will not close emphasis that sits against a space, so the published page rendered literal asterisks and the bold was gone. The editor kept showing it as bold, so nothing looked wrong until publish. Plate is behaving correctly here: it stores `{ text: 'word ', bold: true }`, and `mdast-util-to-markdown` writes `**` + children + `**` verbatim without normalising. ## The fix `normalizeMarkWhitespace` runs on the mdast tree after it is built from the editor value and before markdown is written, on both serializer paths. It hoists edge whitespace out of `strong` / `emphasis` / `delete`, drops marks left with nothing inside, and trims whitespace that lands at a block boundary. ``` Some **word **more → Some **word** more ``` Both v3 and v4 serialize through `serializeMDX`, so both get the fix. ## Related cases, same root cause Found while writing the tests: | Input | Saved before | |---|---| | `{text:' word', bold}` | `** word**` | | `{text:'word ', italic}` | `*word *` | | `{text:'word ', strikethrough}` | `~~word ~~` | | `{text:'word ', bold, italic}` | `***word ***` | | `{text:' ', bold}` | `** **` | | `{text:'', bold}` | `****` | | multi-node marked run | `**ab **` | | mark inside a multi-child link | `[**w **tail](u)` | | 4+ leading spaces in a mark | became an indented code block | Left alone on purpose: `inlineCode` (`to-markdown` already pads it correctly), `highlight` (serializes to ``), and interior whitespace such as `**Hello *world*, again**`, which has to survive. ## Testing Tests came first and failed before the fix landed. 32 new tests across both parser configurations, including round trips that reparse the output. Checked in a real editor against `examples/next/kitchen-sink`: keyboard and toolbar produce byte-identical output, and the rendered page emits ``. An untouched document still saves byte-identical. No churn in the 79 existing fixture files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Isaac Lombard [SSW] <152236421+isaaclombardssw@users.noreply.github.com> --- .changeset/mdx-mark-edge-whitespace.md | 9 + .../@tinacms/mdx/src/next/stringify/index.ts | 5 +- .../mdx/src/next/stringify/to-markdown.ts | 7 +- .../mdx/src/stringify/indentation.test.ts | 83 ++++ packages/@tinacms/mdx/src/stringify/index.ts | 12 +- .../mdx/src/stringify/mark-whitespace.test.ts | 354 ++++++++++++++++++ .../mdx/src/stringify/mark-whitespace.ts | 117 ++++++ .../src/stringify/raw-mode-round-trip.test.ts | 64 ++++ 8 files changed, 645 insertions(+), 6 deletions(-) create mode 100644 .changeset/mdx-mark-edge-whitespace.md create mode 100644 packages/@tinacms/mdx/src/stringify/indentation.test.ts create mode 100644 packages/@tinacms/mdx/src/stringify/mark-whitespace.test.ts create mode 100644 packages/@tinacms/mdx/src/stringify/mark-whitespace.ts create mode 100644 packages/@tinacms/mdx/src/stringify/raw-mode-round-trip.test.ts diff --git a/.changeset/mdx-mark-edge-whitespace.md b/.changeset/mdx-mark-edge-whitespace.md new file mode 100644 index 0000000000..99257b5c52 --- /dev/null +++ b/.changeset/mdx-mark-edge-whitespace.md @@ -0,0 +1,9 @@ +--- +"@tinacms/mdx": patch +--- + +Bold, italic and strikethrough now survive a leading or trailing space in the selection. Selecting `word ` and applying bold used to save `**word **`. CommonMark cannot close emphasis that sits against a space, so the published page showed literal asterisks and the formatting was lost, even though the editor still looked right. The space now sits outside the markers, giving `a **word** more`. + +Indentation at the start of a line is kept as well. A bare space there is whitespace a Markdown parser may discard, and four of them open an indented code block, so an indented line used to reload without its spaces on the `mdx` parser and as a code block on the `markdown` parser. This applies to the first line of a paragraph and to a line broken with Shift+Enter. The leading space is now written as ` `, so the text comes back the way it was left. + +The fix also covers marks holding only whitespace, empty marks, marks spanning several text nodes, marks inside a link, and combined bold and italic. Whitespace inside a mark, as in `**Hello *world*, again**`, still round trips unchanged. diff --git a/packages/@tinacms/mdx/src/next/stringify/index.ts b/packages/@tinacms/mdx/src/next/stringify/index.ts index c7df40586a..d4ae2da5bb 100644 --- a/packages/@tinacms/mdx/src/next/stringify/index.ts +++ b/packages/@tinacms/mdx/src/next/stringify/index.ts @@ -1,5 +1,6 @@ import { RichTextField } from '@tinacms/schema-tools'; import type * as Plate from '../../parse/plate'; +import { normalizeMarkWhitespace } from '../../stringify/mark-whitespace'; import { preProcess } from './pre-processing'; import { toTinaMarkdown } from './to-markdown'; @@ -11,6 +12,8 @@ export const stringifyMDX = ( if (!value) { return; } - const mdTree = preProcess(value, field, imageCallback); + const mdTree = normalizeMarkWhitespace( + preProcess(value, field, imageCallback) + ); return toTinaMarkdown(mdTree, field); }; diff --git a/packages/@tinacms/mdx/src/next/stringify/to-markdown.ts b/packages/@tinacms/mdx/src/next/stringify/to-markdown.ts index 1a1f19e82c..07dd4dd463 100644 --- a/packages/@tinacms/mdx/src/next/stringify/to-markdown.ts +++ b/packages/@tinacms/mdx/src/next/stringify/to-markdown.ts @@ -28,11 +28,14 @@ export const toTinaMarkdown = (tree: Md.Root, field: RichTextField) => { // @ts-ignore const handlers: Handlers = {}; handlers['text'] = (node, parent, context, safeOptions) => { - // Empty spaces before/after strings + // Empty spaces before/after strings. The rule guarding a space at the + // start of a line stays: without it four of them open an indented code + // block, and the author's indentation comes back as `code_block`. context.unsafe = context.unsafe.filter((unsafeItem) => { if ( unsafeItem.character === ' ' && - unsafeItem.inConstruct === 'phrasing' + unsafeItem.inConstruct === 'phrasing' && + unsafeItem.before !== '[\\r\\n]' ) { return false; } diff --git a/packages/@tinacms/mdx/src/stringify/indentation.test.ts b/packages/@tinacms/mdx/src/stringify/indentation.test.ts new file mode 100644 index 0000000000..005119313c --- /dev/null +++ b/packages/@tinacms/mdx/src/stringify/indentation.test.ts @@ -0,0 +1,83 @@ +import type { RichTextField } from '@tinacms/schema-tools'; +import { describe, expect, it } from 'vitest'; +import { parseMDX } from '../parse'; +import type * as Plate from '../parse/plate'; +import { serializeMDX } from './index'; + +const passthrough = (value: string) => value; + +const fields: [string, RichTextField][] = [ + ['mdx', { name: 'body', type: 'rich-text' }], + [ + 'markdown', + { name: 'body', type: 'rich-text', parser: { type: 'markdown' } }, + ], +]; + +const serialize = ( + children: Plate.InlineElement[], + field: RichTextField +): string => { + const result = serializeMDX( + { + type: 'root', + children: [{ type: 'p', children }], + } as Plate.RootElement, + field, + passthrough + ); + if (typeof result !== 'string') { + throw new Error(`Expected a string, received ${typeof result}`); + } + return result; +}; + +describe.each(fields)('line-start indentation (%s parser)', (_, field) => { + /** + * The leading space is written as ` ` because a bare one at the start of + * a line is whitespace the parser is free to drop, and four of them open an + * indented code block. + */ + it('survives a round trip on a continuation line', () => { + const markdown = serialize( + [ + { type: 'text', text: 'first' }, + { type: 'break' }, + { type: 'text', text: ' second' }, + ] as Plate.InlineElement[], + field + ); + expect(markdown).toBe('first\\\n second\n'); + expect( + (parseMDX(markdown, field, passthrough) as Plate.RootElement).children + ).toEqual([ + { + type: 'p', + children: [ + { type: 'text', text: 'first' }, + { type: 'break', children: [{ type: 'text', text: '' }] }, + { type: 'text', text: ' second' }, + ], + }, + ]); + }); + + it('leaves a space before a line ending alone', () => { + expect( + serialize( + [ + { type: 'text', text: 'first ' }, + { type: 'break' }, + { type: 'text', text: 'second' }, + ] as Plate.InlineElement[], + field + ) + ).toBe('first \\\nsecond\n'); + }); + + it('leaves interior spaces alone', () => { + expect(serialize([{ type: 'text', text: 'hello world' }], field)).toBe( + 'hello world\n' + ); + }); +}); diff --git a/packages/@tinacms/mdx/src/stringify/index.ts b/packages/@tinacms/mdx/src/stringify/index.ts index 3005115d7f..8f6d38a9f4 100644 --- a/packages/@tinacms/mdx/src/stringify/index.ts +++ b/packages/@tinacms/mdx/src/stringify/index.ts @@ -12,6 +12,7 @@ import { directiveToMarkdown } from '../extensions/tina-shortcodes/to-markdown'; import { stringifyMDX as stringifyMDXNext } from '../next'; import type * as Plate from '../parse/plate'; import { stringifyProps } from './acorn'; +import { normalizeMarkWhitespace } from './mark-whitespace'; import { eat } from './marks'; import { stringifyShortcode } from './stringifyShortcode'; @@ -54,7 +55,9 @@ export const serializeMDX = ( return value.children[0].value; } } - const tree = rootElement(value, field, imageCallback); + const tree = normalizeMarkWhitespace( + rootElement(value, field, imageCallback) + ); const res = toTinaMarkdown(tree, field); const templatesWithMatchers = field.templates?.filter( (template) => template.match @@ -104,11 +107,14 @@ export const toTinaMarkdown = (tree: Md.Root, field: RichTextType) => { // @ts-ignore const handlers: Handlers = {}; handlers['text'] = (node, parent, context, safeOptions) => { - // Empty spaces before/after strings + // Empty spaces before/after strings. The rule guarding a space at the + // start of a line stays: without it four of them open an indented code + // block, and the author's indentation comes back as `code_block`. context.unsafe = context.unsafe.filter((unsafeItem) => { if ( unsafeItem.character === ' ' && - unsafeItem.inConstruct === 'phrasing' + unsafeItem.inConstruct === 'phrasing' && + unsafeItem.before !== '[\\r\\n]' ) { return false; } diff --git a/packages/@tinacms/mdx/src/stringify/mark-whitespace.test.ts b/packages/@tinacms/mdx/src/stringify/mark-whitespace.test.ts new file mode 100644 index 0000000000..75a48d9aff --- /dev/null +++ b/packages/@tinacms/mdx/src/stringify/mark-whitespace.test.ts @@ -0,0 +1,354 @@ +import type { RichTextField } from '@tinacms/schema-tools'; +import { describe, expect, it } from 'vitest'; +import { parseMDX } from '../parse'; +import type * as Plate from '../parse/plate'; +import { serializeMDX } from './index'; + +const passthrough = (value: string) => value; + +const NBSP = '\u00a0'; + +const fields: [string, RichTextField][] = [ + ['mdx', { name: 'body', type: 'rich-text' }], + [ + 'markdown', + { name: 'body', type: 'rich-text', parser: { type: 'markdown' } }, + ], +]; + +const paragraph = (children: Plate.InlineElement[]): Plate.RootElement => ({ + type: 'root', + children: [{ type: 'p', children }], +}); + +const serializeRoot = ( + value: Plate.RootElement, + field: RichTextField +): string => { + const result = serializeMDX(value, field, passthrough); + if (typeof result !== 'string') { + throw new Error(`Expected a string, received ${typeof result}`); + } + return result; +}; + +const serialize = ( + children: Plate.InlineElement[], + field: RichTextField +): string => serializeRoot(paragraph(children), field); + +const boldTextsOf = (markdown: string, field: RichTextField): string[] => { + const bolds: string[] = []; + const walk = (nodes: { bold?: boolean; text?: string; children?: any[] }[]) => + nodes?.forEach((node) => { + if (node.bold) { + bolds.push(node.text ?? ''); + } + if (node.children) { + walk(node.children); + } + }); + walk((parseMDX(markdown, field, passthrough) as Plate.RootElement).children); + return bolds; +}; + +describe.each(fields)('bold with edge whitespace (%s parser)', (_, field) => { + it('keeps a trailing space outside the bold markers', () => { + expect( + serialize( + [ + { type: 'text', text: 'Some ' }, + { type: 'text', text: 'word ', bold: true }, + { type: 'text', text: 'more' }, + ], + field + ) + ).toBe('Some **word** more\n'); + }); + + it('keeps a leading space outside the bold markers', () => { + expect( + serialize( + [ + { type: 'text', text: 'Some' }, + { type: 'text', text: ' word', bold: true }, + { type: 'text', text: ' more' }, + ], + field + ) + ).toBe('Some **word** more\n'); + }); + + it('keeps whitespace on both sides outside the bold markers', () => { + expect( + serialize( + [ + { type: 'text', text: 'Some' }, + { type: 'text', text: ' word ', bold: true }, + { type: 'text', text: 'more' }, + ], + field + ) + ).toBe('Some **word** more\n'); + }); + + it('emits markdown that reparses as bold', () => { + const markdown = serialize( + [ + { type: 'text', text: 'Some ' }, + { type: 'text', text: 'word ', bold: true }, + { type: 'text', text: 'more' }, + ], + field + ); + expect(boldTextsOf(markdown, field)).toEqual(['word']); + }); +}); + +describe.each(fields)( + 'other marks with edge whitespace (%s parser)', + (_, field) => { + it('keeps whitespace outside the emphasis markers', () => { + expect( + serialize( + [ + { type: 'text', text: 'Some ' }, + { type: 'text', text: 'word ', italic: true }, + { type: 'text', text: 'more' }, + ], + field + ) + ).toBe('Some *word* more\n'); + }); + + it('keeps whitespace outside the strikethrough markers', () => { + expect( + serialize( + [ + { type: 'text', text: 'Some ' }, + { type: 'text', text: 'word ', strikethrough: true }, + { type: 'text', text: 'more' }, + ], + field + ) + ).toBe('Some ~~word~~ more\n'); + }); + + it('keeps whitespace outside combined bold and emphasis markers', () => { + expect( + serialize( + [ + { type: 'text', text: 'Some ' }, + { type: 'text', text: 'word ', bold: true, italic: true }, + { type: 'text', text: 'more' }, + ], + field + ) + ).toBe('Some ***word*** more\n'); + }); + + it('keeps whitespace outside a mark nested in a link', () => { + expect( + serialize( + [ + { + type: 'a', + url: 'https://example.com', + children: [ + { type: 'text', text: 'word ', bold: true }, + { type: 'text', text: 'tail' }, + ], + }, + { type: 'text', text: 'after' }, + ], + field + ) + ).toBe('[**word** tail](https://example.com)after\n'); + }); + } +); + +describe.each(fields)('degenerate marked nodes (%s parser)', (_, field) => { + it('drops the markers from a whitespace-only mark', () => { + expect( + serialize( + [ + { type: 'text', text: 'Some' }, + { type: 'text', text: ' ', bold: true }, + { type: 'text', text: 'more' }, + ], + field + ) + ).toBe('Some more\n'); + }); + + it('drops the markers from an empty mark', () => { + expect( + serialize( + [ + { type: 'text', text: 'Some ' }, + { type: 'text', text: '', bold: true }, + { type: 'text', text: 'more' }, + ], + field + ) + ).toBe('Some more\n'); + }); + + it('takes the trailing space from the last node of a marked run', () => { + expect( + serialize( + [ + { type: 'text', text: 'a', bold: true }, + { type: 'text', text: 'b ', bold: true }, + { type: 'text', text: 'more' }, + ], + field + ) + ).toBe('**ab** more\n'); + }); +}); + +describe.each(fields)('block boundaries (%s parser)', (_, field) => { + it('does not leave whitespace at the end of the block', () => { + expect( + serialize([{ type: 'text', text: 'word ', bold: true }], field) + ).toBe('**word**\n'); + }); + + it('does not indent the block when the mark has leading whitespace', () => { + expect( + serialize([{ type: 'text', text: ' word', bold: true }], field) + ).toBe('**word**\n'); + }); + + it('keeps a trailing non-breaking space when no mark is involved', () => { + expect(serialize([{ type: 'text', text: `hello${NBSP}` }], field)).toBe( + `hello${NBSP}\n` + ); + }); + + it('keeps non-breaking space indentation before a link', () => { + expect( + serialize( + [ + { type: 'text', text: NBSP.repeat(2) }, + { + type: 'a', + url: 'https://e.com', + title: null, + children: [{ type: 'text', text: 'Watch' }], + }, + ] as Plate.InlineElement[], + field + ) + ).toBe(`${NBSP.repeat(2)}[Watch](https://e.com)\n`); + }); + + it('keeps a leading space on a heading', () => { + expect( + serializeRoot( + { + type: 'root', + children: [ + { type: 'h2', children: [{ type: 'text', text: ' Title' }] }, + ], + } as Plate.RootElement, + field + ) + ).toBe('## Title\n'); + }); + + it('writes author indentation so it comes back as a paragraph', () => { + const markdown = serialize([{ type: 'text', text: ' word' }], field); + expect(markdown).toBe(' word\n'); + expect( + (parseMDX(markdown, field, passthrough) as Plate.RootElement).children + ).toEqual([{ type: 'p', children: [{ type: 'text', text: ' word' }] }]); + }); + + it('leaves author indentation next to whitespace the hoist moved', () => { + expect( + serialize( + [ + { type: 'text', text: ' ' }, + { type: 'text', text: ' bold', bold: true }, + ], + field + ) + ).toBe(' **bold**\n'); + }); + + it('keeps a whitespace-only spacer paragraph', () => { + const markdown = serializeRoot( + { + type: 'root', + children: [ + { type: 'p', children: [{ type: 'text', text: 'one' }] }, + { type: 'p', children: [{ type: 'text', text: NBSP }] }, + { type: 'p', children: [{ type: 'text', text: 'two' }] }, + ], + } as Plate.RootElement, + field + ); + expect( + (parseMDX(markdown, field, passthrough) as Plate.RootElement).children + ).toHaveLength(3); + }); +}); + +describe.each(fields)('table cells (%s parser)', (_, field) => { + const cell = (children: Plate.InlineElement[]) => ({ + type: 'td', + children: [{ type: 'p', children }], + }); + + /** + * GFM strips whatever sits against the cell delimiters, so author whitespace + * at a cell edge cannot survive a reload no matter what is written. Pinning + * that here so nobody extends the author carve-out to cells expecting it to. + */ + it('discards author whitespace at a cell edge on reload', () => { + const markdown = serializeRoot( + { + type: 'root', + children: [ + { + type: 'table', + children: [ + { + type: 'tr', + children: [cell([{ type: 'text', text: 'head' }])], + }, + { + type: 'tr', + children: [ + cell([ + { type: 'text', text: 'word ', bold: true }, + { type: 'text', text: ' ' }, + ]), + ], + }, + ], + }, + ], + } as unknown as Plate.RootElement, + field + ); + const [table] = ( + parseMDX(markdown, field, passthrough) as Plate.RootElement + ).children as any[]; + const [, row] = table.children; + expect(row.children[0].children[0].children).toEqual([ + { type: 'text', text: 'word', bold: true }, + ]); + }); +}); + +describe.each(fields)('interior whitespace (%s parser)', (_, field) => { + it('is preserved when a mark wraps other marks', () => { + const markdown = '**Hello *world*, again**\n'; + const tree = parseMDX(markdown, field, passthrough) as Plate.RootElement; + expect(serializeMDX(tree, field, passthrough)).toBe(markdown); + }); +}); diff --git a/packages/@tinacms/mdx/src/stringify/mark-whitespace.ts b/packages/@tinacms/mdx/src/stringify/mark-whitespace.ts new file mode 100644 index 0000000000..12a5f4da94 --- /dev/null +++ b/packages/@tinacms/mdx/src/stringify/mark-whitespace.ts @@ -0,0 +1,117 @@ +import type * as Md from 'mdast'; + +type Parent = { type?: string; children: Md.PhrasingContent[] }; + +const MARKS = new Set(['strong', 'emphasis', 'delete']); + +/** + * Whitespace this pass moves to a block edge was never at the edge in the + * editor: at the start of a paragraph it reloads as indentation nobody typed, + * and in a table cell it widens the column on every save. It is cleared there. + * Whitespace already at the edge is the author's, and is left alone. + */ +const BLOCK_BOUNDARIES = new Set(['paragraph', 'heading', 'tableCell']); + +const asParent = (node: Md.PhrasingContent): Parent | null => + Array.isArray((node as Parent).children) ? (node as unknown as Parent) : null; + +const isEmpty = (node: Md.PhrasingContent): boolean => { + if (node.type === 'text') { + return node.value === ''; + } + const parent = asParent(node); + return parent ? parent.children.every(isEmpty) : false; +}; + +/** + * Removes the whitespace at one edge of a node and returns it, descending + * through nested marks. Anything else — a link, an image, inline code — owns + * its whitespace, so nothing is taken. + */ +const takeEdge = (node: Md.PhrasingContent, edge: 'lead' | 'trail'): string => { + if (node.type === 'text') { + const [whitespace = ''] = + node.value.match(edge === 'lead' ? /^\s+/ : /\s+$/) ?? []; + node.value = + edge === 'lead' + ? node.value.slice(whitespace.length) + : node.value.slice(0, node.value.length - whitespace.length); + return whitespace; + } + const parent = MARKS.has(node.type) ? asParent(node) : null; + const child = + edge === 'lead' ? parent?.children.at(0) : parent?.children.at(-1); + return child ? takeEdge(child, edge) : ''; +}; + +const mergeText = (children: Md.PhrasingContent[]): Md.PhrasingContent[] => + children.reduce((merged, child) => { + const previous = merged.at(-1); + if (child.type === 'text' && previous?.type === 'text') { + previous.value += child.value; + return merged; + } + merged.push(child); + return merged; + }, []); + +const hoistFromMarks = (node: Parent) => { + const hoisted: Md.PhrasingContent[] = []; + const fromHoist = new Set(); + const hoist = (value: string) => { + const text: Md.Text = { type: 'text', value }; + fromHoist.add(text); + hoisted.push(text); + }; + + for (const child of node.children) { + if (!MARKS.has(child.type)) { + hoisted.push(child); + continue; + } + const lead = takeEdge(child, 'lead'); + const trail = takeEdge(child, 'trail'); + if (isEmpty(child)) { + hoist(lead + trail); + continue; + } + if (lead) { + hoist(lead); + } + hoisted.push(child); + if (trail) { + hoist(trail); + } + } + + if (node.type && BLOCK_BOUNDARIES.has(node.type)) { + for (const edge of [hoisted.at(0), hoisted.at(-1)]) { + if (edge?.type === 'text' && fromHoist.has(edge)) { + edge.value = ''; + } + } + } + // Runs after the edge clearing: merging discards the node identity `fromHoist` + // is keyed on, so an earlier merge makes every edge look like the author's. + node.children = mergeText(hoisted); +}; + +/** + * Marks created in the editor can hold leading or trailing whitespace — a word + * selected along with the space after it. Markdown emphasis markers cannot sit + * next to whitespace, so that whitespace is moved out of the mark and marks + * left with nothing are dropped. Mutates the tree in place. + */ +export const normalizeMarkWhitespace = (tree: Md.Root): Md.Root => { + const visit = (node: Parent) => { + node.children.forEach((child) => { + const parent = asParent(child); + if (parent) { + visit(parent); + } + }); + hoistFromMarks(node); + }; + visit(tree as unknown as Parent); + return tree; +}; diff --git a/packages/@tinacms/mdx/src/stringify/raw-mode-round-trip.test.ts b/packages/@tinacms/mdx/src/stringify/raw-mode-round-trip.test.ts new file mode 100644 index 0000000000..c7ffc5a7b3 --- /dev/null +++ b/packages/@tinacms/mdx/src/stringify/raw-mode-round-trip.test.ts @@ -0,0 +1,64 @@ +import type { RichTextField } from '@tinacms/schema-tools'; +import { describe, expect, it } from 'vitest'; +import { parseMDX } from '../parse'; +import type * as Plate from '../parse/plate'; +import { serializeMDX } from './index'; + +const passthrough = (v: string) => v; + +const fields: [string, RichTextField][] = [ + ['mdx', { name: 'body', type: 'rich-text' }], + [ + 'markdown', + { name: 'body', type: 'rich-text', parser: { type: 'markdown' } }, + ], +]; + +const editorValue = (): Plate.RootElement => ({ + type: 'root', + children: [ + { + type: 'p', + children: [ + { type: 'text', text: 'Alpha ' }, + { type: 'text', text: 'bravo ', bold: true }, + { type: 'text', text: 'charlie' }, + ], + }, + ], +}); + +/** + * The raw-markdown toggle serializes the live editor value to a string and + * parses it straight back, so anything the pair does not agree on surfaces as + * "Unable to parse rich-text" — see + * packages/@tinacms/app/src/fields/rich-text/monaco/index.tsx. + */ +describe.each(fields)('raw markdown toggle (%s parser)', (_, field) => { + it('round-trips a bold run that carries a trailing space', () => { + const markdown = serializeMDX(editorValue(), field, passthrough); + if (typeof markdown !== 'string') { + throw new Error(`Expected a string, received ${typeof markdown}`); + } + expect(markdown).toBe('Alpha **bravo** charlie\n'); + + const reparsed = parseMDX( + markdown, + field, + passthrough + ) as Plate.RootElement; + expect(reparsed.children[0]?.type).not.toBe('invalid_markdown'); + expect((reparsed.children[0] as any).children).toEqual([ + { type: 'text', text: 'Alpha ' }, + { type: 'text', text: 'bravo', bold: true }, + { type: 'text', text: ' charlie' }, + ]); + }); + + it('leaves the editor value it was handed untouched', () => { + const value = editorValue(); + const before = JSON.stringify(value); + serializeMDX(value, field, passthrough); + expect(JSON.stringify(value)).toBe(before); + }); +});