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!
+
+
+
+[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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
');
+ });
+});
+
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
+
+