@tinacms/astro 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,12 @@
1
1
  import type { AstroIntegration } from 'astro';
2
2
  export interface TinaIntegrationOptions {
3
3
  middlewareOrder?: 'pre' | 'post';
4
+ /**
5
+ * Force the Cloudflare Workers (workerd) `import.meta.url` workaround on or
6
+ * off. When omitted, it is applied automatically whenever the
7
+ * `@astrojs/cloudflare` adapter is detected. Set `false` to opt out, or
8
+ * `true` to force it for a custom Cloudflare setup.
9
+ */
10
+ cloudflareWorkers?: boolean;
4
11
  }
5
12
  export default function tina(options?: TinaIntegrationOptions): AstroIntegration;
@@ -4,9 +4,13 @@ import { createRequire } from "node:module";
4
4
  import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  var BRIDGE_ROUTE = "/admin/bridge.js";
7
+ var CLOUDFLARE_ADAPTER_NAME = "@astrojs/cloudflare";
8
+ var WORKERD_IMPORT_META_URL = JSON.stringify("file:///worker.mjs");
9
+ var SERVER_ENVIRONMENTS = /* @__PURE__ */ new Set(["ssr", "astro"]);
7
10
  function tina(options = {}) {
8
11
  const { middlewareOrder = "pre" } = options;
9
12
  let clientDir;
13
+ let isCloudflareAdapter = false;
10
14
  return {
11
15
  name: "@tinacms/astro",
12
16
  hooks: {
@@ -15,10 +19,18 @@ function tina(options = {}) {
15
19
  entrypoint: "@tinacms/astro/middleware",
16
20
  order: middlewareOrder
17
21
  });
18
- updateConfig({ vite: { plugins: [bridgeDevPlugin()] } });
22
+ updateConfig({
23
+ vite: {
24
+ plugins: [
25
+ bridgeDevPlugin(),
26
+ cloudflareImportMetaUrlPlugin(() => isCloudflareAdapter)
27
+ ]
28
+ }
29
+ });
19
30
  },
20
31
  "astro:config:done": ({ config }) => {
21
32
  clientDir = config.build.client;
33
+ isCloudflareAdapter = options.cloudflareWorkers ?? config.adapter?.name === CLOUDFLARE_ADAPTER_NAME;
22
34
  },
23
35
  "astro:build:done": ({ logger }) => {
24
36
  if (!clientDir) return;
@@ -54,6 +66,18 @@ function bridgeDevPlugin() {
54
66
  }
55
67
  };
56
68
  }
69
+ function cloudflareImportMetaUrlPlugin(isCloudflare) {
70
+ const define = { "import.meta.url": WORKERD_IMPORT_META_URL };
71
+ return {
72
+ name: "@tinacms/astro:cloudflare-import-meta-url",
73
+ // No `apply`: the define is needed in both the build (ssr) and dev envs.
74
+ configEnvironment(name) {
75
+ if (!isCloudflare()) return;
76
+ if (!SERVER_ENVIRONMENTS.has(name)) return;
77
+ return { define };
78
+ }
79
+ };
80
+ }
57
81
  function emitBridgeAsset(adminDir, logger) {
58
82
  try {
59
83
  mkdirSync(adminDir, { recursive: true });
package/dist/types.d.ts CHANGED
@@ -35,10 +35,54 @@ export type CodeBlockElement = {
35
35
  children: TextElement[];
36
36
  }[];
37
37
  };
38
- export type BlockElement = {
38
+ /** Per-column horizontal alignment for table cells. */
39
+ export type TableAlign = 'left' | 'right' | 'center';
40
+ /** A paragraph — the block wrapper Tina puts around inline content. */
41
+ export type ParagraphElement = {
39
42
  type: 'p';
40
43
  children: InlineElement[];
41
- } | {
44
+ };
45
+ /** A single table cell. Its content is wrapped in a paragraph by the parser. */
46
+ export type TableCellElement = {
47
+ type: 'td';
48
+ children: ParagraphElement[];
49
+ };
50
+ export type TableRowElement = {
51
+ type: 'tr';
52
+ children: TableCellElement[];
53
+ };
54
+ /**
55
+ * Native markdown table node (the shape `@tinacms/mdx` emits from a GFM
56
+ * table). Rows/cells are plain `tr`/`td`; per-column text-align lives on
57
+ * `props.align`. Mirrors `TableElement` in
58
+ * `packages/@tinacms/mdx/src/parse/plate.ts`.
59
+ */
60
+ export type TableElement = {
61
+ type: 'table';
62
+ props?: {
63
+ align?: TableAlign[];
64
+ };
65
+ children: TableRowElement[];
66
+ };
67
+ /** A cell in the legacy MDX-flow table; its `value` is itself rich text. */
68
+ export type MdxTableCell = {
69
+ value: TinaRichTextContent;
70
+ };
71
+ export type MdxTableRow = {
72
+ tableCells: MdxTableCell[];
73
+ };
74
+ /**
75
+ * Props of the legacy MDX-flow table — an `mdxJsxFlowElement` named `table`
76
+ * whose cells live on `props.tableRows` rather than as `tr`/`td` child nodes.
77
+ * Older editors produced this shape; `MdxTableNode.astro` still renders it.
78
+ */
79
+ export type MdxTableProps = {
80
+ align?: TableAlign[];
81
+ /** When true, the first row is rendered as a `<thead>` of `<th>`. */
82
+ firstRowHeader?: boolean;
83
+ tableRows?: MdxTableRow[];
84
+ };
85
+ export type BlockElement = ParagraphElement | {
42
86
  type: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
43
87
  children: InlineElement[];
44
88
  } | {
@@ -57,7 +101,7 @@ export type BlockElement = {
57
101
  type: 'hr';
58
102
  } | {
59
103
  type: 'break';
60
- } | ImageElement | CodeBlockElement | {
104
+ } | ImageElement | CodeBlockElement | TableElement | {
61
105
  type: 'maybe_mdx';
62
106
  } | {
63
107
  type: 'html';
@@ -88,5 +132,86 @@ export type MdxElement = {
88
132
  children?: TinaRichTextNode[];
89
133
  };
90
134
  export type TinaRichTextContent = TinaRichTextRoot | TinaRichTextNode[] | null | undefined;
91
- /** A map of mdxJsx name (or default tag override) → Astro component. */
92
- export type CustomComponentsMap = Record<string, AstroComponent>;
135
+ /**
136
+ * An Astro (or framework) component that accepts props `P`. Astro's language
137
+ * server types an imported `.astro` component as `(props: Props) => any`, so
138
+ * this both accepts such a component and checks that its `Props` match what
139
+ * the renderer passes to the override.
140
+ */
141
+ export type AstroComponentWithProps<P> = (props: P) => any;
142
+ /** Props passed to an `a` (link) override. */
143
+ export interface LinkComponentProps {
144
+ url: string;
145
+ }
146
+ /** Props passed to an `img` (image) override. */
147
+ export interface ImageComponentProps {
148
+ url: string;
149
+ alt?: string;
150
+ caption?: string;
151
+ }
152
+ /** Props passed to a `code_block` override. */
153
+ export interface CodeBlockComponentProps {
154
+ value: string;
155
+ lang?: string;
156
+ }
157
+ /** Props passed to `html` / `html_inline` overrides. */
158
+ export interface HtmlComponentProps {
159
+ value: string;
160
+ }
161
+ /** Props passed to a `table` override (native table node). */
162
+ export interface TableComponentProps {
163
+ node: TableElement;
164
+ }
165
+ /** Props passed to `td` / `th` overrides. */
166
+ export interface TableCellComponentProps {
167
+ align?: TableAlign;
168
+ }
169
+ /**
170
+ * Map of component name → Astro component, passed to `<TinaMarkdown>`.
171
+ *
172
+ * The built-in keys below are suggested by editor autocomplete and override
173
+ * the matching default element/tag; the named-prop overrides are typed with
174
+ * the exact props the renderer passes (e.g. `code_block` receives
175
+ * `{ value, lang }`).
176
+ *
177
+ * Custom rich-text **templates** render as mdxJsx nodes dispatched by `name`,
178
+ * with their fields passed as props. Register one under its template name.
179
+ * By default a custom key accepts any component; supply the optional `Custom`
180
+ * type param to type them precisely and have the registration checked:
181
+ *
182
+ * ```ts
183
+ * // template `cta` with a `title: string` field
184
+ * const components: CustomComponentsMap<{ cta: { title: string } }> = {
185
+ * cta: Cta, // ← Cta's Props must be assignable from `{ title: string }`
186
+ * };
187
+ * ```
188
+ */
189
+ export type CustomComponentsMap<Custom extends Record<string, object> = Record<never, never>> = {
190
+ p?: AstroComponent;
191
+ h1?: AstroComponent;
192
+ h2?: AstroComponent;
193
+ h3?: AstroComponent;
194
+ h4?: AstroComponent;
195
+ h5?: AstroComponent;
196
+ h6?: AstroComponent;
197
+ ul?: AstroComponent;
198
+ ol?: AstroComponent;
199
+ li?: AstroComponent;
200
+ lic?: AstroComponent;
201
+ blockquote?: AstroComponent;
202
+ tr?: AstroComponent;
203
+ hr?: AstroComponent;
204
+ break?: AstroComponent;
205
+ a?: AstroComponentWithProps<LinkComponentProps>;
206
+ img?: AstroComponentWithProps<ImageComponentProps>;
207
+ code_block?: AstroComponentWithProps<CodeBlockComponentProps>;
208
+ html?: AstroComponentWithProps<HtmlComponentProps>;
209
+ html_inline?: AstroComponentWithProps<HtmlComponentProps>;
210
+ table?: AstroComponentWithProps<TableComponentProps>;
211
+ td?: AstroComponentWithProps<TableCellComponentProps>;
212
+ th?: AstroComponentWithProps<TableCellComponentProps>;
213
+ } & {
214
+ [K in keyof Custom]?: AstroComponentWithProps<Custom[K]>;
215
+ } & {
216
+ [name: string]: AstroComponent | undefined;
217
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinacms/astro",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "main": "src/TinaMarkdown.astro",
6
6
  "types": "dist/index.d.ts",
package/src/MdxNode.astro CHANGED
@@ -2,9 +2,13 @@
2
2
  /**
3
3
  * mdxJsx{Flow,Text}Element nodes are dispatched by `node.name` — registered
4
4
  * components on the `components` map render with the node's props spread in.
5
- * Unregistered names emit a visible placeholder so missing registrations
6
- * surface during development.
5
+ *
6
+ * When no component is registered for the name, an `mdxJsxFlowElement` named
7
+ * `table` renders the built-in legacy table (`MdxTableNode`), mirroring the
8
+ * React renderer. Any other unregistered name emits a visible placeholder so
9
+ * missing registrations surface during development.
7
10
  */
11
+ import MdxTableNode from './MdxTableNode.astro';
8
12
  import type { CustomComponentsMap, MdxElement } from './types';
9
13
 
10
14
  interface Props {
@@ -13,10 +17,17 @@ interface Props {
13
17
  }
14
18
 
15
19
  const { node, components } = Astro.props;
20
+ // Single place that guarantees mdx props are an object, so neither the
21
+ // component spread nor the legacy-table renderer downstream needs its own
22
+ // guard. Parsed content always sets props; this only defends malformed nodes.
23
+ const props: Record<string, unknown> = node.props ?? {};
16
24
  const MdxComponent = components[node.name];
25
+ const isLegacyTable = !MdxComponent && node.name === 'table';
17
26
  ---
18
27
  {MdxComponent ? (
19
- <MdxComponent {...(node.props ?? {})} />
28
+ <MdxComponent {...props} />
29
+ ) : isLegacyTable ? (
30
+ <MdxTableNode node={{ ...node, props }} components={components} />
20
31
  ) : (
21
32
  <span style="display:inline-block;padding:0.25rem 0.5rem;background:#fee;color:#900;border-radius:0.25rem;font-family:monospace;font-size:0.85em;">
22
33
  No component provided for {node.name}
@@ -0,0 +1,84 @@
1
+ ---
2
+ /**
3
+ * Renders the legacy MDX-flow table shape — an `mdxJsxFlowElement` named
4
+ * `table` whose cells live on `props.tableRows` (rather than as `tr`/`td`
5
+ * child nodes). Mirrors the matching branch of
6
+ * `packages/tinacms/src/rich-text/index.tsx`: when `props.firstRowHeader` is
7
+ * set the first row becomes a `<thead>` of `<th>` and the rest are `<td>` in
8
+ * `<tbody>`. Per-column alignment comes from `props.align`, and the `th` / `td`
9
+ * overrides apply. The `table` / `tr` overrides are intentionally NOT honored
10
+ * here: the `table` override is typed to receive a native `{ node:
11
+ * TableElement }`, which this legacy `MdxElement`-based shape can't provide.
12
+ */
13
+ import TinaMarkdown from './TinaMarkdown.astro';
14
+ import type {
15
+ CustomComponentsMap,
16
+ InlineElement,
17
+ MdxElement,
18
+ MdxTableProps,
19
+ TinaRichTextContent,
20
+ } from './types';
21
+
22
+ interface Props {
23
+ node: MdxElement;
24
+ components: CustomComponentsMap;
25
+ }
26
+
27
+ const { node, components } = Astro.props;
28
+ // `node.props` is guaranteed to be an object — MdxNode defaults it before
29
+ // dispatching here — so no further guard is needed at this layer.
30
+ const props = node.props as MdxTableProps;
31
+
32
+ const align = props.align ?? [];
33
+ const allRows = props.tableRows ?? [];
34
+ const header = props.firstRowHeader ? allRows.at(0) : undefined;
35
+ const bodyRows = props.firstRowHeader ? allRows.slice(1) : allRows;
36
+
37
+ const ThOverride = components.th;
38
+ const TdOverride = components.td;
39
+
40
+ // A cell `value` is rich text; unwrap each wrapping paragraph to its inline
41
+ // nodes so a cell renders `<td>text</td>` rather than `<td><p>text</p></td>`.
42
+ const cellInline = (value: TinaRichTextContent): InlineElement[] => {
43
+ const nodes = Array.isArray(value) ? value : (value?.children ?? []);
44
+ return nodes.flatMap((n) => (n.type === 'p' ? n.children : []));
45
+ };
46
+
47
+ const cellStyle = (i: number) => `text-align:${align[i] ?? 'auto'}`;
48
+ ---
49
+ <table>
50
+ {header && (
51
+ <thead>
52
+ <tr>
53
+ {(header.tableCells ?? []).map((cell, i) =>
54
+ ThOverride ? (
55
+ <ThOverride align={align[i]}>
56
+ <TinaMarkdown content={cellInline(cell.value)} components={components} />
57
+ </ThOverride>
58
+ ) : (
59
+ <th style={cellStyle(i)}>
60
+ <TinaMarkdown content={cellInline(cell.value)} components={components} />
61
+ </th>
62
+ )
63
+ )}
64
+ </tr>
65
+ </thead>
66
+ )}
67
+ <tbody>
68
+ {bodyRows.map((row) => (
69
+ <tr>
70
+ {(row?.tableCells ?? []).map((cell, i) =>
71
+ TdOverride ? (
72
+ <TdOverride align={align[i]}>
73
+ <TinaMarkdown content={cellInline(cell.value)} components={components} />
74
+ </TdOverride>
75
+ ) : (
76
+ <td style={cellStyle(i)}>
77
+ <TinaMarkdown content={cellInline(cell.value)} components={components} />
78
+ </td>
79
+ )
80
+ )}
81
+ </tr>
82
+ ))}
83
+ </tbody>
84
+ </table>
package/src/Node.astro CHANGED
@@ -12,12 +12,14 @@ import ImageNode from './ImageNode.astro';
12
12
  import Leaf from './Leaf.astro';
13
13
  import LinkNode from './LinkNode.astro';
14
14
  import MdxNode from './MdxNode.astro';
15
+ import TableNode from './TableNode.astro';
15
16
  import type {
16
17
  CodeBlockElement,
17
18
  CustomComponentsMap,
18
19
  ImageElement,
19
20
  LinkElement,
20
21
  MdxElement,
22
+ TableElement,
21
23
  TextElement,
22
24
  TinaRichTextNode,
23
25
  } from './types';
@@ -54,6 +56,8 @@ const containerTypes = new Set([
54
56
  <ImageNode node={node as ImageElement} components={components} />
55
57
  ) : t === 'code_block' ? (
56
58
  <CodeBlockNode node={node as CodeBlockElement} components={components} />
59
+ ) : t === 'table' ? (
60
+ <TableNode node={node as TableElement} components={components} />
57
61
  ) : t === 'text' ? (
58
62
  <Leaf node={node as TextElement} />
59
63
  ) : t === 'mdxJsxFlowElement' || t === 'mdxJsxTextElement' ? (
@@ -0,0 +1,65 @@
1
+ ---
2
+ /**
3
+ * Renders a native markdown `table` node. Mirrors the `case 'table'` branch
4
+ * in `packages/tinacms/src/rich-text/index.tsx`: every row goes into a single
5
+ * `<tbody>` as `<td>` cells (the first row is NOT promoted to a header — that
6
+ * matches the React renderer's native-table path), per-column alignment comes
7
+ * from `node.props.align`, and `components.table` / `.tr` / `.td` override the
8
+ * default tags. Each cell's content is the inline content of its paragraph,
9
+ * rendered without a wrapping `<p>` (the equivalent of React's `p` → `td`
10
+ * override).
11
+ */
12
+ import TinaMarkdown from './TinaMarkdown.astro';
13
+ import type {
14
+ CustomComponentsMap,
15
+ InlineElement,
16
+ TableCellElement,
17
+ TableElement,
18
+ } from './types';
19
+
20
+ interface Props {
21
+ node: TableElement;
22
+ components: CustomComponentsMap;
23
+ }
24
+
25
+ const { node, components } = Astro.props;
26
+
27
+ const TableOverride = components.table;
28
+ const TrOverride = components.tr;
29
+ const TdOverride = components.td;
30
+
31
+ const align = node.props?.align ?? [];
32
+ const rows = node.children ?? [];
33
+
34
+ const TABLE_STYLE = 'border:1px solid #EDECF3';
35
+ const tdStyle = (i: number) =>
36
+ `text-align:${align[i] ?? 'auto'};border:1px solid #EDECF3;padding:0.25rem`;
37
+
38
+ // A cell wraps its content in a paragraph; unwrap to the inline nodes so the
39
+ // cell renders `<td>text</td>` rather than `<td><p>text</p></td>`.
40
+ const cellContent = (cell: TableCellElement): InlineElement[] =>
41
+ (cell?.children ?? []).flatMap((paragraph) => paragraph?.children ?? []);
42
+
43
+ const Table = TableOverride ?? 'table';
44
+ const tableProps = TableOverride ? { node } : { style: TABLE_STYLE };
45
+ const Tr = TrOverride ?? 'tr';
46
+ ---
47
+ <Table {...tableProps}>
48
+ <tbody>
49
+ {rows.map((row) => (
50
+ <Tr>
51
+ {(row?.children ?? []).map((cell, i) =>
52
+ TdOverride ? (
53
+ <TdOverride align={align[i]}>
54
+ <TinaMarkdown content={cellContent(cell)} components={components} />
55
+ </TdOverride>
56
+ ) : (
57
+ <td style={tdStyle(i)}>
58
+ <TinaMarkdown content={cellContent(cell)} components={components} />
59
+ </td>
60
+ )
61
+ )}
62
+ </Tr>
63
+ ))}
64
+ </tbody>
65
+ </Table>
@@ -6,6 +6,8 @@ import codeBlock from './fixtures/code-block.json';
6
6
  import leafMarks from './fixtures/leaf-marks.json';
7
7
  import mdxJsxFlow from './fixtures/mdx-jsx-flow.json';
8
8
  import mdxJsxText from './fixtures/mdx-jsx-text.json';
9
+ import mdxTable from './fixtures/mdx-table.json';
10
+ import table from './fixtures/table.json';
9
11
 
10
12
  let container: AstroContainer;
11
13
 
@@ -84,6 +86,51 @@ describe('TinaMarkdown', () => {
84
86
  });
85
87
  });
86
88
 
89
+ describe('TinaMarkdown — tables', () => {
90
+ it('renders a native table node with rows, cells and column alignment', async () => {
91
+ const html = await render({ props: { content: table } });
92
+ expect(html).toMatchSnapshot();
93
+ expect(html).toContain('<table');
94
+ expect(html).toContain('<tbody>');
95
+ expect(html).toContain('<tr>');
96
+ expect(html).toContain('<td');
97
+ // Per-column alignment from props.align.
98
+ expect(html).toContain('text-align:left');
99
+ expect(html).toContain('text-align:center');
100
+ expect(html).toContain('text-align:right');
101
+ // Inline marks render inside cells.
102
+ expect(html).toContain('<strong>Ada</strong>');
103
+ // No <thead>/<th> on the native path (matches the React renderer).
104
+ expect(html).not.toContain('<thead>');
105
+ expect(html).not.toContain('<th');
106
+ // Cell content is not wrapped in an extra <p>.
107
+ expect(html).not.toMatch(/<td[^>]*><p[\s>]/);
108
+ });
109
+
110
+ it('overrides the table tag via components.table', async () => {
111
+ const FancyTable = await import('./fixtures/FancyTable.astro');
112
+ const html = await render({
113
+ props: { content: table, components: { table: FancyTable.default } },
114
+ });
115
+ expect(html).toContain('class="fancy-table"');
116
+ expect(html).toContain('Name');
117
+ expect(html).toContain('Eng');
118
+ });
119
+
120
+ it('renders the legacy mdx-flow table with a header row', async () => {
121
+ const html = await render({ props: { content: mdxTable } });
122
+ expect(html).toMatchSnapshot();
123
+ expect(html).toContain('<thead>');
124
+ expect(html).toContain('<th');
125
+ expect(html).toContain('Header A');
126
+ expect(html).toContain('<tbody>');
127
+ expect(html).toContain('a1');
128
+ expect(html).toContain('text-align:left');
129
+ expect(html).toContain('text-align:right');
130
+ expect(html).not.toContain('No component provided for table');
131
+ });
132
+ });
133
+
87
134
  describe('TinaMarkdown — components map', () => {
88
135
  it('dispatches a registered mdxJsx component by name', async () => {
89
136
  const MyFeature = await import('./fixtures/MyFeature.astro');
@@ -5,3 +5,7 @@ exports[`TinaMarkdown > renders code blocks with language class 1`] = `"<pre><co
5
5
  exports[`TinaMarkdown > renders nested leaf-mark formatting 1`] = `"<p>Some <strong>bold</strong> text</p><p>Some <strong><em>bold and emphasized</em></strong> text</p><p>Marks with <em>emphasized text nesting </em><strong><em>bold</em></strong><em> text</em></p><p><strong>Hello </strong><strong><em>world</em></strong><strong>, again</strong> <em>here</em></p><p>Some <code>inline code</code> examples</p><p><em>Hello </em><em><code>some code</code></em><em>, again</em></p><p><strong>Hello </strong><a href="https://example.com"><strong>world</strong></a></p><p><strong><em>Hello </em></strong><a href="https://example.com"><strong><em>world</em></strong></a><em> And some other text, which has a </em><a href="https://something.com"><em>link to something</em></a></p>"`;
6
6
 
7
7
  exports[`TinaMarkdown > renders the basic kitchen-sink fixture 1`] = `"<h1>Heading</h1><p>A paragraph.</p><blockquote>A quote.</blockquote><ul><li><div>An item</div></li></ul><hr><p>A <a href="http://example.com">link</a>.</p><p><img src="https://get.svg.workers.dev" alt="Alt Text"></p>"`;
8
+
9
+ exports[`TinaMarkdown — tables > renders a native table node with rows, cells and column alignment 1`] = `"<table style="border:1px solid #EDECF3"> <tbody> <tr><td style="text-align:left;border:1px solid #EDECF3;padding:0.25rem"> Name </td><td style="text-align:center;border:1px solid #EDECF3;padding:0.25rem"> Role </td><td style="text-align:right;border:1px solid #EDECF3;padding:0.25rem"> Score </td></tr><tr><td style="text-align:left;border:1px solid #EDECF3;padding:0.25rem"> <strong>Ada</strong> </td><td style="text-align:center;border:1px solid #EDECF3;padding:0.25rem"> Eng </td><td style="text-align:right;border:1px solid #EDECF3;padding:0.25rem"> 99 </td></tr> </tbody> </table>"`;
10
+
11
+ exports[`TinaMarkdown — tables > renders the legacy mdx-flow table with a header row 1`] = `"<table> <thead> <tr> <th style="text-align:left"> Header A </th><th style="text-align:right"> Header B </th> </tr> </thead> <tbody> <tr> <td style="text-align:left"> a1 </td><td style="text-align:right"> b1 </td> </tr> </tbody> </table>"`;
@@ -0,0 +1,3 @@
1
+ ---
2
+ ---
3
+ <table class="fancy-table"><slot /></table>
@@ -0,0 +1,44 @@
1
+ {
2
+ "type": "root",
3
+ "children": [
4
+ {
5
+ "type": "mdxJsxFlowElement",
6
+ "name": "table",
7
+ "props": {
8
+ "firstRowHeader": true,
9
+ "align": ["left", "right"],
10
+ "tableRows": [
11
+ {
12
+ "tableCells": [
13
+ {
14
+ "value": [
15
+ { "type": "p", "children": [{ "type": "text", "text": "Header A" }] }
16
+ ]
17
+ },
18
+ {
19
+ "value": [
20
+ { "type": "p", "children": [{ "type": "text", "text": "Header B" }] }
21
+ ]
22
+ }
23
+ ]
24
+ },
25
+ {
26
+ "tableCells": [
27
+ {
28
+ "value": [
29
+ { "type": "p", "children": [{ "type": "text", "text": "a1" }] }
30
+ ]
31
+ },
32
+ {
33
+ "value": [
34
+ { "type": "p", "children": [{ "type": "text", "text": "b1" }] }
35
+ ]
36
+ }
37
+ ]
38
+ }
39
+ ]
40
+ },
41
+ "children": [{ "type": "text", "text": "" }]
42
+ }
43
+ ]
44
+ }
@@ -0,0 +1,60 @@
1
+ {
2
+ "type": "root",
3
+ "children": [
4
+ {
5
+ "type": "table",
6
+ "props": { "align": ["left", "center", "right"] },
7
+ "children": [
8
+ {
9
+ "type": "tr",
10
+ "children": [
11
+ {
12
+ "type": "td",
13
+ "children": [
14
+ { "type": "p", "children": [{ "type": "text", "text": "Name" }] }
15
+ ]
16
+ },
17
+ {
18
+ "type": "td",
19
+ "children": [
20
+ { "type": "p", "children": [{ "type": "text", "text": "Role" }] }
21
+ ]
22
+ },
23
+ {
24
+ "type": "td",
25
+ "children": [
26
+ { "type": "p", "children": [{ "type": "text", "text": "Score" }] }
27
+ ]
28
+ }
29
+ ]
30
+ },
31
+ {
32
+ "type": "tr",
33
+ "children": [
34
+ {
35
+ "type": "td",
36
+ "children": [
37
+ {
38
+ "type": "p",
39
+ "children": [{ "type": "text", "text": "Ada", "bold": true }]
40
+ }
41
+ ]
42
+ },
43
+ {
44
+ "type": "td",
45
+ "children": [
46
+ { "type": "p", "children": [{ "type": "text", "text": "Eng" }] }
47
+ ]
48
+ },
49
+ {
50
+ "type": "td",
51
+ "children": [
52
+ { "type": "p", "children": [{ "type": "text", "text": "99" }] }
53
+ ]
54
+ }
55
+ ]
56
+ }
57
+ ]
58
+ }
59
+ ]
60
+ }
@@ -12,11 +12,11 @@ type ConfigSetupArg = Parameters<NonNullable<Hooks['astro:config:setup']>>[0];
12
12
  type ConfigDoneArg = Parameters<NonNullable<Hooks['astro:config:done']>>[0];
13
13
  type BuildDoneArg = Parameters<NonNullable<Hooks['astro:build:done']>>[0];
14
14
 
15
- function runConfigSetup() {
15
+ function runConfigSetup(options?: Parameters<typeof tina>[0]) {
16
16
  const addMiddleware = vi.fn();
17
17
  const updateConfig = vi.fn();
18
18
  const logger = { warn: vi.fn(), info: vi.fn() };
19
- const integration = tina();
19
+ const integration = tina(options);
20
20
  (
21
21
  integration.hooks['astro:config:setup'] as NonNullable<
22
22
  Hooks['astro:config:setup']
@@ -28,6 +28,34 @@ function runConfigSetup() {
28
28
  return { integration, addMiddleware, updateConfig, logger, plugins };
29
29
  }
30
30
 
31
+ // Drive the astro:config:done hook with a chosen adapter so the integration can
32
+ // resolve whether the Cloudflare workaround should apply.
33
+ function runConfigDone(integration: AstroIntegration, adapterName?: string) {
34
+ (
35
+ integration.hooks['astro:config:done'] as NonNullable<
36
+ Hooks['astro:config:done']
37
+ >
38
+ )({
39
+ config: {
40
+ build: { client: pathToFileURL('/tmp/tina-client/') },
41
+ adapter: adapterName ? { name: adapterName, hooks: {} } : undefined,
42
+ },
43
+ } as unknown as ConfigDoneArg);
44
+ }
45
+
46
+ const cfPlugin = (plugins: VitePlugin[]) =>
47
+ plugins.find(
48
+ (p) => p.name === '@tinacms/astro:cloudflare-import-meta-url'
49
+ ) as VitePlugin;
50
+
51
+ // Invoke a plugin's `configEnvironment` hook (a function in our plugin) without
52
+ // a real Vite environment — it only branches on the environment name.
53
+ function runConfigEnvironment(plugin: VitePlugin, name: string) {
54
+ const hook = plugin.configEnvironment as any;
55
+ const fn = typeof hook === 'function' ? hook : hook?.handler;
56
+ return fn?.call({}, name, {}, {});
57
+ }
58
+
31
59
  // Drive a Vite plugin's `configureServer` and capture the request handler it
32
60
  // registers, so we can exercise it without a real dev server.
33
61
  function devHandler(plugin: VitePlugin) {
@@ -96,6 +124,82 @@ describe('tina() integration — bridge dev plugin', () => {
96
124
  });
97
125
  });
98
126
 
127
+ describe('tina() integration — cloudflare import.meta.url plugin', () => {
128
+ const EXPECTED = JSON.stringify('file:///worker.mjs');
129
+
130
+ it('injects a valid import.meta.url define for the workerd server envs under the cloudflare adapter', () => {
131
+ const { integration, plugins } = runConfigSetup();
132
+ runConfigDone(integration, '@astrojs/cloudflare');
133
+ const plugin = cfPlugin(plugins);
134
+ expect(plugin).toBeDefined();
135
+
136
+ for (const env of ['ssr', 'astro']) {
137
+ const result = runConfigEnvironment(plugin, env);
138
+ expect(result?.define?.['import.meta.url']).toBe(EXPECTED);
139
+ // The placeholder must itself be a valid absolute URL.
140
+ expect(
141
+ () => new URL(JSON.parse(result.define['import.meta.url']))
142
+ ).not.toThrow();
143
+ }
144
+ });
145
+
146
+ it('never injects the define into the client or prerender envs', () => {
147
+ const { integration, plugins } = runConfigSetup();
148
+ runConfigDone(integration, '@astrojs/cloudflare');
149
+ const plugin = cfPlugin(plugins);
150
+ // `client` keeps the real import.meta.url; `prerender` may run in Node.
151
+ expect(runConfigEnvironment(plugin, 'client')).toBeUndefined();
152
+ expect(runConfigEnvironment(plugin, 'prerender')).toBeUndefined();
153
+ });
154
+
155
+ it('does not inject the define for non-cloudflare adapters', () => {
156
+ for (const adapter of ['@astrojs/node', '@astrojs/vercel']) {
157
+ const { integration, plugins } = runConfigSetup();
158
+ runConfigDone(integration, adapter);
159
+ const plugin = cfPlugin(plugins);
160
+ for (const env of ['ssr', 'prerender', 'astro', 'client']) {
161
+ expect(runConfigEnvironment(plugin, env)).toBeUndefined();
162
+ }
163
+ }
164
+ });
165
+
166
+ it('does not inject the define when no adapter is configured', () => {
167
+ const { integration, plugins } = runConfigSetup();
168
+ runConfigDone(integration);
169
+ expect(runConfigEnvironment(cfPlugin(plugins), 'ssr')).toBeUndefined();
170
+ });
171
+
172
+ it('reads the adapter flag lazily, after config:done', () => {
173
+ const { integration, plugins } = runConfigSetup();
174
+ const plugin = cfPlugin(plugins);
175
+ // Before config:done the flag is false, so the plugin is inert.
176
+ expect(runConfigEnvironment(plugin, 'ssr')).toBeUndefined();
177
+ // The same plugin instance picks up the adapter once config:done runs.
178
+ runConfigDone(integration, '@astrojs/cloudflare');
179
+ expect(
180
+ runConfigEnvironment(plugin, 'ssr')?.define?.['import.meta.url']
181
+ ).toBe(EXPECTED);
182
+ });
183
+
184
+ it('honours the cloudflareWorkers option override', () => {
185
+ // Force on, even with a Node adapter.
186
+ const forcedOn = runConfigSetup({ cloudflareWorkers: true });
187
+ runConfigDone(forcedOn.integration, '@astrojs/node');
188
+ expect(
189
+ runConfigEnvironment(cfPlugin(forcedOn.plugins), 'ssr')?.define?.[
190
+ 'import.meta.url'
191
+ ]
192
+ ).toBe(EXPECTED);
193
+
194
+ // Force off, even with the Cloudflare adapter.
195
+ const forcedOff = runConfigSetup({ cloudflareWorkers: false });
196
+ runConfigDone(forcedOff.integration, '@astrojs/cloudflare');
197
+ expect(
198
+ runConfigEnvironment(cfPlugin(forcedOff.plugins), 'ssr')
199
+ ).toBeUndefined();
200
+ });
201
+ });
202
+
99
203
  describe('tina() integration — astro:build:done', () => {
100
204
  it('emits bridge.js into the client output dir', () => {
101
205
  const clientDir = mkdtempSync(join(tmpdir(), 'tina-client-'));
@@ -7,11 +7,32 @@ import type { Plugin as VitePlugin } from 'vite';
7
7
 
8
8
  export interface TinaIntegrationOptions {
9
9
  middlewareOrder?: 'pre' | 'post';
10
+ /**
11
+ * Force the Cloudflare Workers (workerd) `import.meta.url` workaround on or
12
+ * off. When omitted, it is applied automatically whenever the
13
+ * `@astrojs/cloudflare` adapter is detected. Set `false` to opt out, or
14
+ * `true` to force it for a custom Cloudflare setup.
15
+ */
16
+ cloudflareWorkers?: boolean;
10
17
  }
11
18
 
12
19
  /** Where the injected bridge bootstrap imports the bridge bundle from. */
13
20
  const BRIDGE_ROUTE = '/admin/bridge.js';
14
21
 
22
+ const CLOUDFLARE_ADAPTER_NAME = '@astrojs/cloudflare';
23
+ /** A valid absolute URL that stands in for `import.meta.url` in server bundles. */
24
+ const WORKERD_IMPORT_META_URL = JSON.stringify('file:///worker.mjs');
25
+ /**
26
+ * The Vite environments that run on workerd under `@astrojs/cloudflare` and
27
+ * therefore need the placeholder: `ssr` (the on-demand server build) and `astro`
28
+ * (the dev SSR runtime). The `client` bundle is excluded so the browser keeps
29
+ * the real `import.meta.url`, and `prerender` is excluded because it runs in
30
+ * real Node when `prerenderEnvironment: 'node'` (faking the URL there would
31
+ * break genuine file resolution) — and the island route is `prerender: false`,
32
+ * so it never renders during prerendering anyway.
33
+ */
34
+ const SERVER_ENVIRONMENTS = new Set(['ssr', 'astro']);
35
+
15
36
  export default function tina(
16
37
  options: TinaIntegrationOptions = {}
17
38
  ): AstroIntegration {
@@ -19,6 +40,10 @@ export default function tina(
19
40
  // Resolved client output dir, captured at config:done and consumed at
20
41
  // build:done (only then is the final output location known).
21
42
  let clientDir: URL | undefined;
43
+ // Whether the Cloudflare adapter is in use, resolved at config:done. The
44
+ // injected Vite plugin reads it lazily (via a thunk) because the plugin is
45
+ // constructed at config:setup, before the adapter is known.
46
+ let isCloudflareAdapter = false;
22
47
 
23
48
  return {
24
49
  name: '@tinacms/astro',
@@ -32,10 +57,23 @@ export default function tina(
32
57
  // it into the user's source tree — config-time writes churn
33
58
  // public/admin on every `astro dev`/`astro build`, break on read-only
34
59
  // or sandboxed filesystems, and can race `tinacms build`.
35
- updateConfig({ vite: { plugins: [bridgeDevPlugin()] } });
60
+ updateConfig({
61
+ vite: {
62
+ plugins: [
63
+ bridgeDevPlugin(),
64
+ cloudflareImportMetaUrlPlugin(() => isCloudflareAdapter),
65
+ ],
66
+ },
67
+ });
36
68
  },
37
69
  'astro:config:done': ({ config }) => {
38
70
  clientDir = config.build.client;
71
+ // config:done runs before Vite is created and after every integration
72
+ // (including a late-registered adapter) has resolved, so config.adapter
73
+ // is reliable here.
74
+ isCloudflareAdapter =
75
+ options.cloudflareWorkers ??
76
+ config.adapter?.name === CLOUDFLARE_ADAPTER_NAME;
39
77
  },
40
78
  'astro:build:done': ({ logger }) => {
41
79
  // Build: emit the bridge next to the admin SPA in the *output* tree.
@@ -82,6 +120,36 @@ function bridgeDevPlugin(): VitePlugin {
82
120
  };
83
121
  }
84
122
 
123
+ /**
124
+ * Cloudflare Workers (workerd) runs the server bundle in a runtime where the
125
+ * bundled `import.meta.url` is not a valid absolute URL. Astro's experimental
126
+ * Container API — used by the on-demand island route — calls
127
+ * `new URL(import.meta.url)` while building its manifest, which throws
128
+ * "Invalid URL string" and 500s the island render in production. The paths it
129
+ * seeds from that URL are never dereferenced for an in-memory render, so a
130
+ * valid placeholder is harmless. We inject it as a Vite `define`, but only in
131
+ * the server environments (never the client bundle, which needs the real value)
132
+ * and only under the Cloudflare adapter (faking it on a Node server would break
133
+ * real file resolution).
134
+ *
135
+ * TODO: remove once Astro guards the unconditional `new URL(import.meta.url)` in
136
+ * createManifest (tracked upstream in withastro/astro).
137
+ */
138
+ function cloudflareImportMetaUrlPlugin(
139
+ isCloudflare: () => boolean
140
+ ): VitePlugin {
141
+ const define = { 'import.meta.url': WORKERD_IMPORT_META_URL };
142
+ return {
143
+ name: '@tinacms/astro:cloudflare-import-meta-url',
144
+ // No `apply`: the define is needed in both the build (ssr) and dev envs.
145
+ configEnvironment(name) {
146
+ if (!isCloudflare()) return;
147
+ if (!SERVER_ENVIRONMENTS.has(name)) return;
148
+ return { define };
149
+ },
150
+ };
151
+ }
152
+
85
153
  function emitBridgeAsset(adminDir: string, logger: AstroIntegrationLogger) {
86
154
  try {
87
155
  mkdirSync(adminDir, { recursive: true });
package/src/types.ts CHANGED
@@ -44,8 +44,57 @@ export type CodeBlockElement = {
44
44
  children?: { children: TextElement[] }[];
45
45
  };
46
46
 
47
+ /** Per-column horizontal alignment for table cells. */
48
+ export type TableAlign = 'left' | 'right' | 'center';
49
+
50
+ /** A paragraph — the block wrapper Tina puts around inline content. */
51
+ export type ParagraphElement = {
52
+ type: 'p';
53
+ children: InlineElement[];
54
+ };
55
+
56
+ /** A single table cell. Its content is wrapped in a paragraph by the parser. */
57
+ export type TableCellElement = {
58
+ type: 'td';
59
+ children: ParagraphElement[];
60
+ };
61
+
62
+ export type TableRowElement = {
63
+ type: 'tr';
64
+ children: TableCellElement[];
65
+ };
66
+
67
+ /**
68
+ * Native markdown table node (the shape `@tinacms/mdx` emits from a GFM
69
+ * table). Rows/cells are plain `tr`/`td`; per-column text-align lives on
70
+ * `props.align`. Mirrors `TableElement` in
71
+ * `packages/@tinacms/mdx/src/parse/plate.ts`.
72
+ */
73
+ export type TableElement = {
74
+ type: 'table';
75
+ props?: { align?: TableAlign[] };
76
+ children: TableRowElement[];
77
+ };
78
+
79
+ /** A cell in the legacy MDX-flow table; its `value` is itself rich text. */
80
+ export type MdxTableCell = { value: TinaRichTextContent };
81
+
82
+ export type MdxTableRow = { tableCells: MdxTableCell[] };
83
+
84
+ /**
85
+ * Props of the legacy MDX-flow table — an `mdxJsxFlowElement` named `table`
86
+ * whose cells live on `props.tableRows` rather than as `tr`/`td` child nodes.
87
+ * Older editors produced this shape; `MdxTableNode.astro` still renders it.
88
+ */
89
+ export type MdxTableProps = {
90
+ align?: TableAlign[];
91
+ /** When true, the first row is rendered as a `<thead>` of `<th>`. */
92
+ firstRowHeader?: boolean;
93
+ tableRows?: MdxTableRow[];
94
+ };
95
+
47
96
  export type BlockElement =
48
- | { type: 'p'; children: InlineElement[] }
97
+ | ParagraphElement
49
98
  | {
50
99
  type: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
51
100
  children: InlineElement[];
@@ -58,6 +107,7 @@ export type BlockElement =
58
107
  | { type: 'break' }
59
108
  | ImageElement
60
109
  | CodeBlockElement
110
+ | TableElement
61
111
  | { type: 'maybe_mdx' }
62
112
  | { type: 'html'; value: string }
63
113
  | { type: 'invalid_markdown'; value: string };
@@ -93,5 +143,94 @@ export type TinaRichTextContent =
93
143
  | null
94
144
  | undefined;
95
145
 
96
- /** A map of mdxJsx name (or default tag override) → Astro component. */
97
- export type CustomComponentsMap = Record<string, AstroComponent>;
146
+ /**
147
+ * An Astro (or framework) component that accepts props `P`. Astro's language
148
+ * server types an imported `.astro` component as `(props: Props) => any`, so
149
+ * this both accepts such a component and checks that its `Props` match what
150
+ * the renderer passes to the override.
151
+ */
152
+ export type AstroComponentWithProps<P> = (props: P) => any;
153
+
154
+ /** Props passed to an `a` (link) override. */
155
+ export interface LinkComponentProps {
156
+ url: string;
157
+ }
158
+ /** Props passed to an `img` (image) override. */
159
+ export interface ImageComponentProps {
160
+ url: string;
161
+ alt?: string;
162
+ caption?: string;
163
+ }
164
+ /** Props passed to a `code_block` override. */
165
+ export interface CodeBlockComponentProps {
166
+ value: string;
167
+ lang?: string;
168
+ }
169
+ /** Props passed to `html` / `html_inline` overrides. */
170
+ export interface HtmlComponentProps {
171
+ value: string;
172
+ }
173
+ /** Props passed to a `table` override (native table node). */
174
+ export interface TableComponentProps {
175
+ node: TableElement;
176
+ }
177
+ /** Props passed to `td` / `th` overrides. */
178
+ export interface TableCellComponentProps {
179
+ align?: TableAlign;
180
+ }
181
+
182
+ /**
183
+ * Map of component name → Astro component, passed to `<TinaMarkdown>`.
184
+ *
185
+ * The built-in keys below are suggested by editor autocomplete and override
186
+ * the matching default element/tag; the named-prop overrides are typed with
187
+ * the exact props the renderer passes (e.g. `code_block` receives
188
+ * `{ value, lang }`).
189
+ *
190
+ * Custom rich-text **templates** render as mdxJsx nodes dispatched by `name`,
191
+ * with their fields passed as props. Register one under its template name.
192
+ * By default a custom key accepts any component; supply the optional `Custom`
193
+ * type param to type them precisely and have the registration checked:
194
+ *
195
+ * ```ts
196
+ * // template `cta` with a `title: string` field
197
+ * const components: CustomComponentsMap<{ cta: { title: string } }> = {
198
+ * cta: Cta, // ← Cta's Props must be assignable from `{ title: string }`
199
+ * };
200
+ * ```
201
+ */
202
+ export type CustomComponentsMap<
203
+ Custom extends Record<string, object> = Record<never, never>,
204
+ > = {
205
+ // Block/inline overrides whose content comes through the default slot.
206
+ p?: AstroComponent;
207
+ h1?: AstroComponent;
208
+ h2?: AstroComponent;
209
+ h3?: AstroComponent;
210
+ h4?: AstroComponent;
211
+ h5?: AstroComponent;
212
+ h6?: AstroComponent;
213
+ ul?: AstroComponent;
214
+ ol?: AstroComponent;
215
+ li?: AstroComponent;
216
+ lic?: AstroComponent;
217
+ blockquote?: AstroComponent;
218
+ tr?: AstroComponent;
219
+ hr?: AstroComponent;
220
+ break?: AstroComponent;
221
+ // Overrides that receive named props.
222
+ a?: AstroComponentWithProps<LinkComponentProps>;
223
+ img?: AstroComponentWithProps<ImageComponentProps>;
224
+ code_block?: AstroComponentWithProps<CodeBlockComponentProps>;
225
+ html?: AstroComponentWithProps<HtmlComponentProps>;
226
+ html_inline?: AstroComponentWithProps<HtmlComponentProps>;
227
+ table?: AstroComponentWithProps<TableComponentProps>;
228
+ td?: AstroComponentWithProps<TableCellComponentProps>;
229
+ th?: AstroComponentWithProps<TableCellComponentProps>;
230
+ } & {
231
+ // Custom templates declared via the `Custom` param — typed by their props.
232
+ [K in keyof Custom]?: AstroComponentWithProps<Custom[K]>;
233
+ } & {
234
+ // Any other custom mdxJsx component name (matched by `node.name`).
235
+ [name: string]: AstroComponent | undefined;
236
+ };