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/.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/.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/.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/.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/.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/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 0000000000..94ed1c4878 Binary files /dev/null and b/examples/web-components/kitchen-sink/example-image.jpg differ diff --git a/examples/web-components/kitchen-sink/index.html b/examples/web-components/kitchen-sink/index.html new file mode 100644 index 0000000000..d8a644ce40 --- /dev/null +++ b/examples/web-components/kitchen-sink/index.html @@ -0,0 +1,166 @@ + + + + + + + + + + + + + +

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/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); + }); +}); 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/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/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 { 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/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} 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 9ce7a5f356..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,12 +375,15 @@ 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 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 @@ -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 @@ -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 @@ -1419,7 +1429,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 +1559,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 +1920,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 @@ -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: @@ -2036,7 +2068,7 @@ importers: version: 5.27.14 tar: specifier: 'catalog:' - version: 7.4.0 + version: 7.5.21 devDependencies: '@tinacms/scripts': specifier: workspace:* @@ -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 @@ -15119,15 +15148,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'} @@ -24778,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: @@ -33518,16 +33546,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 @@ -33713,7 +33740,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 +33758,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): @@ -34534,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 @@ -34796,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 @@ -34814,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 6f5c6a69fe..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,8 +162,9 @@ 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.25.0 + esbuild: ^0.28.1 estree-util-is-identifier-name: 2.1.0 fast-glob: ^3.3.3 final-form: 4.20.10 @@ -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 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 // ------------------------------------------------------------------