@tinacms/astro 0.2.0 → 0.3.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.
- package/README.md +18 -5
- package/dist/data.d.ts +7 -4
- package/dist/data.js +8 -3
- package/dist/data.test-d.d.ts +1 -0
- package/dist/experimental.js +39 -9
- package/dist/index.js +8 -3
- package/dist/integration.d.ts +0 -20
- package/dist/integration.js +52 -5
- package/dist/internal/admin-origin.d.ts +6 -0
- package/dist/internal/forms-store.d.ts +3 -14
- package/dist/island-route.d.ts +0 -25
- package/dist/island-route.js +39 -9
- package/dist/middleware.d.ts +0 -17
- package/dist/middleware.js +26 -12
- package/dist/vite.d.ts +21 -0
- package/dist/vite.js +24 -0
- package/package.json +9 -10
- package/src/TinaIsland.astro +25 -23
- package/src/__tests__/IslandStub.astro +8 -0
- package/src/__tests__/TinaIsland.test.ts +60 -0
- package/src/__tests__/forms-store.test.ts +70 -0
- package/src/__tests__/integration.test.ts +124 -0
- package/src/__tests__/island-route.test.ts +119 -0
- package/src/__tests__/middleware.test.ts +102 -0
- package/src/__tests__/vite.test.ts +67 -0
- package/src/data.test-d.ts +53 -0
- package/src/data.ts +13 -37
- package/src/integration.ts +74 -29
- package/src/internal/admin-origin.ts +19 -0
- package/src/internal/forms-store.ts +27 -18
- package/src/island-route.ts +37 -38
- package/src/middleware.ts +19 -48
- package/src/vite.ts +40 -0
- package/dist/bridge-route.d.ts +0 -3
- package/dist/bridge-route.js +0 -22
- package/src/bridge-route.ts +0 -33
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tinacms/astro",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/TinaMarkdown.astro",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -57,15 +57,14 @@
|
|
|
57
57
|
"astro": "./src/integration.ts",
|
|
58
58
|
"default": "./dist/integration.js"
|
|
59
59
|
},
|
|
60
|
-
"./bridge-route": {
|
|
61
|
-
"types": "./dist/bridge-route.d.ts",
|
|
62
|
-
"astro": "./src/bridge-route.ts",
|
|
63
|
-
"default": "./dist/bridge-route.js"
|
|
64
|
-
},
|
|
65
60
|
"./experimental": {
|
|
66
61
|
"types": "./dist/experimental.d.ts",
|
|
67
62
|
"astro": "./src/experimental.ts",
|
|
68
63
|
"default": "./dist/experimental.js"
|
|
64
|
+
},
|
|
65
|
+
"./vite": {
|
|
66
|
+
"types": "./dist/vite.d.ts",
|
|
67
|
+
"default": "./dist/vite.js"
|
|
69
68
|
}
|
|
70
69
|
},
|
|
71
70
|
"license": "Apache-2.0",
|
|
@@ -108,21 +107,21 @@
|
|
|
108
107
|
"target": "node"
|
|
109
108
|
},
|
|
110
109
|
{
|
|
111
|
-
"name": "src/
|
|
110
|
+
"name": "src/experimental.ts",
|
|
112
111
|
"target": "node"
|
|
113
112
|
},
|
|
114
113
|
{
|
|
115
|
-
"name": "src/
|
|
114
|
+
"name": "src/island-route.ts",
|
|
116
115
|
"target": "node"
|
|
117
116
|
},
|
|
118
117
|
{
|
|
119
|
-
"name": "src/
|
|
118
|
+
"name": "src/vite.ts",
|
|
120
119
|
"target": "node"
|
|
121
120
|
}
|
|
122
121
|
]
|
|
123
122
|
},
|
|
124
123
|
"dependencies": {
|
|
125
|
-
"@tinacms/bridge": "0.
|
|
124
|
+
"@tinacms/bridge": "0.3.0"
|
|
126
125
|
},
|
|
127
126
|
"peerDependencies": {
|
|
128
127
|
"astro": "^5.0.0"
|
package/src/TinaIsland.astro
CHANGED
|
@@ -1,32 +1,18 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* attribute the bridge looks for, with the wrapper element/className taken
|
|
5
|
-
* from the registry entry — so the page-side element matches the server's
|
|
6
|
-
* `IslandConfig.wrapper` automatically.
|
|
7
|
-
*
|
|
8
|
-
* Usage:
|
|
9
|
-
*
|
|
10
|
-
* ```astro
|
|
11
|
-
* import TinaIsland from '@tinacms/astro/TinaIsland.astro';
|
|
12
|
-
* import { islands } from '../lib/islands';
|
|
13
|
-
*
|
|
14
|
-
* <TinaIsland name="post" wrapper={islands.post.wrapper} params={{ slug }}>
|
|
15
|
-
* <PostBody data={data} />
|
|
16
|
-
* </TinaIsland>
|
|
17
|
-
* ```
|
|
18
|
-
*
|
|
19
|
-
* `name` must match a key in the `IslandRegistry` you passed to
|
|
20
|
-
* `experimental_createIslandRoute`. `params` is encoded as URL search
|
|
21
|
-
* params and forwarded to the route handler.
|
|
22
|
-
*/
|
|
2
|
+
import { adminOrigins } from './internal/admin-origin';
|
|
3
|
+
|
|
23
4
|
interface Props {
|
|
24
5
|
name: string;
|
|
25
6
|
wrapper: { tag: string; className?: string };
|
|
26
7
|
params?: Record<string, string | number>;
|
|
8
|
+
/** Mark at most one `<TinaIsland>` per page as the primary editable
|
|
9
|
+
* region so the admin opens it on load instead of the multi-document
|
|
10
|
+
* picker. SSR pages don't need this — the first `requestWithMetadata()`
|
|
11
|
+
* is treated as primary automatically. */
|
|
12
|
+
primary?: boolean;
|
|
27
13
|
}
|
|
28
14
|
|
|
29
|
-
const { name, wrapper, params } = Astro.props;
|
|
15
|
+
const { name, wrapper, params, primary } = Astro.props;
|
|
30
16
|
const search = params
|
|
31
17
|
? `?${new URLSearchParams(
|
|
32
18
|
Object.fromEntries(
|
|
@@ -36,7 +22,23 @@ const search = params
|
|
|
36
22
|
: '';
|
|
37
23
|
const marker = `/tina-island/${name}${search}`;
|
|
38
24
|
const Tag = wrapper.tag as keyof HTMLElementTagNameMap;
|
|
25
|
+
const adminOrigin = adminOrigins();
|
|
39
26
|
---
|
|
40
|
-
|
|
27
|
+
<!-- In-iframe bridge bootstrap for prerendered pages (the middleware only
|
|
28
|
+
injects on SSR responses). No-op outside the admin iframe; the
|
|
29
|
+
window flag dedupes across multiple <TinaIsland> on one page. -->
|
|
30
|
+
<script is:inline type="module" define:vars={{ adminOrigin }}>
|
|
31
|
+
if (window.self !== window.top && !window.__tinaBootstrap) {
|
|
32
|
+
window.__tinaBootstrap = 1;
|
|
33
|
+
const { init, refreshForms } = await import('/admin/bridge.js');
|
|
34
|
+
init(adminOrigin ? { adminOrigin } : undefined);
|
|
35
|
+
document.addEventListener('astro:page-load', refreshForms);
|
|
36
|
+
}
|
|
37
|
+
</script>
|
|
38
|
+
<Tag
|
|
39
|
+
class={wrapper.className}
|
|
40
|
+
data-tina-island={marker}
|
|
41
|
+
data-tina-island-primary={primary ? '' : undefined}
|
|
42
|
+
>
|
|
41
43
|
<slot />
|
|
42
44
|
</Tag>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
|
|
2
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import TinaIsland from '../TinaIsland.astro';
|
|
4
|
+
|
|
5
|
+
let container: AstroContainer;
|
|
6
|
+
|
|
7
|
+
beforeEach(async () => {
|
|
8
|
+
container = await AstroContainer.create();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const wrapper = { tag: 'article', className: 'prose' };
|
|
12
|
+
|
|
13
|
+
describe('TinaIsland', () => {
|
|
14
|
+
it('emits the data-tina-island marker with encoded params', async () => {
|
|
15
|
+
const html = await container.renderToString(TinaIsland, {
|
|
16
|
+
props: { name: 'post', wrapper, params: { slug: 'hello-world' } },
|
|
17
|
+
slots: { default: 'BODY' },
|
|
18
|
+
});
|
|
19
|
+
expect(html).toContain(
|
|
20
|
+
'data-tina-island="/tina-island/post?slug=hello-world"'
|
|
21
|
+
);
|
|
22
|
+
expect(html).toContain('BODY');
|
|
23
|
+
expect(html).toContain('class="prose"');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('emits the guarded in-iframe bootstrap script', async () => {
|
|
27
|
+
const html = await container.renderToString(TinaIsland, {
|
|
28
|
+
props: { name: 'post', wrapper },
|
|
29
|
+
slots: { default: 'BODY' },
|
|
30
|
+
});
|
|
31
|
+
expect(html).toContain('window.__tinaBootstrap');
|
|
32
|
+
expect(html).toContain("import('/admin/bridge.js')");
|
|
33
|
+
expect(html).toContain('window.self !== window.top');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('threads the resolved admin origin through define:vars', async () => {
|
|
37
|
+
const html = await container.renderToString(TinaIsland, {
|
|
38
|
+
props: { name: 'post', wrapper },
|
|
39
|
+
slots: { default: 'BODY' },
|
|
40
|
+
});
|
|
41
|
+
// `define:vars` emits the value resolved from PUBLIC_TINA_ADMIN_ORIGIN —
|
|
42
|
+
// `null` here since it's unset in the test env.
|
|
43
|
+
expect(html).toContain('adminOrigin = null');
|
|
44
|
+
expect(html).toContain('init(adminOrigin ? { adminOrigin } : undefined)');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('marks the wrapper data-tina-island-primary only when primary is set', async () => {
|
|
48
|
+
const withoutPrimary = await container.renderToString(TinaIsland, {
|
|
49
|
+
props: { name: 'post', wrapper },
|
|
50
|
+
slots: { default: 'BODY' },
|
|
51
|
+
});
|
|
52
|
+
expect(withoutPrimary).not.toContain('data-tina-island-primary');
|
|
53
|
+
|
|
54
|
+
const withPrimary = await container.renderToString(TinaIsland, {
|
|
55
|
+
props: { name: 'post', wrapper, primary: true },
|
|
56
|
+
slots: { default: 'BODY' },
|
|
57
|
+
});
|
|
58
|
+
expect(withPrimary).toContain('data-tina-island-primary');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
type CollectedForm,
|
|
4
|
+
formsStore,
|
|
5
|
+
recordForm,
|
|
6
|
+
sortByPriority,
|
|
7
|
+
} from '../internal/forms-store';
|
|
8
|
+
|
|
9
|
+
function makeForm(overrides: Partial<CollectedForm> = {}): CollectedForm {
|
|
10
|
+
return {
|
|
11
|
+
id: 'id',
|
|
12
|
+
query: 'query Q',
|
|
13
|
+
variables: {},
|
|
14
|
+
data: {},
|
|
15
|
+
...overrides,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('recordForm', () => {
|
|
20
|
+
it('dedupes by id within a single request scope', () => {
|
|
21
|
+
const list: CollectedForm[] = [];
|
|
22
|
+
formsStore.run(list, () => {
|
|
23
|
+
recordForm(makeForm({ id: 'a' }));
|
|
24
|
+
recordForm(makeForm({ id: 'a' }));
|
|
25
|
+
recordForm(makeForm({ id: 'b' }));
|
|
26
|
+
});
|
|
27
|
+
expect(list.map((f) => f.id)).toEqual(['a', 'b']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('upgrades an existing entry to primary when a later call asserts it', () => {
|
|
31
|
+
const list: CollectedForm[] = [];
|
|
32
|
+
formsStore.run(list, () => {
|
|
33
|
+
// Layout calls global with no priority first.
|
|
34
|
+
recordForm(makeForm({ id: 'global' }));
|
|
35
|
+
// Page-level loader for the same id later flags it primary.
|
|
36
|
+
recordForm(makeForm({ id: 'global', priority: 'primary' }));
|
|
37
|
+
});
|
|
38
|
+
expect(list).toHaveLength(1);
|
|
39
|
+
expect(list[0]!.priority).toBe('primary');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('is a no-op outside a request scope', () => {
|
|
43
|
+
// Just don't throw — there's no store to read.
|
|
44
|
+
expect(() => recordForm(makeForm())).not.toThrow();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('sortByPriority', () => {
|
|
49
|
+
it('moves primaries to the front while preserving relative order', () => {
|
|
50
|
+
const forms = [
|
|
51
|
+
makeForm({ id: 'a' }),
|
|
52
|
+
makeForm({ id: 'b', priority: 'primary' }),
|
|
53
|
+
makeForm({ id: 'c' }),
|
|
54
|
+
makeForm({ id: 'd', priority: 'primary' }),
|
|
55
|
+
];
|
|
56
|
+
expect(sortByPriority(forms).map((f) => f.id)).toEqual([
|
|
57
|
+
'b',
|
|
58
|
+
'd',
|
|
59
|
+
'a',
|
|
60
|
+
'c',
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns a new array without mutating the input', () => {
|
|
65
|
+
const forms = [makeForm({ id: 'a' }), makeForm({ id: 'b' })];
|
|
66
|
+
const sorted = sortByPriority(forms);
|
|
67
|
+
expect(sorted).not.toBe(forms);
|
|
68
|
+
expect(forms.map((f) => f.id)).toEqual(['a', 'b']);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join, sep } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import type { AstroIntegration } from 'astro';
|
|
6
|
+
import type { Plugin as VitePlugin } from 'vite';
|
|
7
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
8
|
+
import tina from '../integration';
|
|
9
|
+
|
|
10
|
+
type Hooks = AstroIntegration['hooks'];
|
|
11
|
+
type ConfigSetupArg = Parameters<NonNullable<Hooks['astro:config:setup']>>[0];
|
|
12
|
+
type ConfigDoneArg = Parameters<NonNullable<Hooks['astro:config:done']>>[0];
|
|
13
|
+
type BuildDoneArg = Parameters<NonNullable<Hooks['astro:build:done']>>[0];
|
|
14
|
+
|
|
15
|
+
function runConfigSetup() {
|
|
16
|
+
const addMiddleware = vi.fn();
|
|
17
|
+
const updateConfig = vi.fn();
|
|
18
|
+
const logger = { warn: vi.fn(), info: vi.fn() };
|
|
19
|
+
const integration = tina();
|
|
20
|
+
(
|
|
21
|
+
integration.hooks['astro:config:setup'] as NonNullable<
|
|
22
|
+
Hooks['astro:config:setup']
|
|
23
|
+
>
|
|
24
|
+
)({ addMiddleware, updateConfig, logger } as unknown as ConfigSetupArg);
|
|
25
|
+
|
|
26
|
+
const plugins: VitePlugin[] =
|
|
27
|
+
updateConfig.mock.calls[0]?.[0]?.vite?.plugins ?? [];
|
|
28
|
+
return { integration, addMiddleware, updateConfig, logger, plugins };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Drive a Vite plugin's `configureServer` and capture the request handler it
|
|
32
|
+
// registers, so we can exercise it without a real dev server.
|
|
33
|
+
function devHandler(plugin: VitePlugin) {
|
|
34
|
+
let handler: ((req: any, res: any, next: () => void) => void) | undefined;
|
|
35
|
+
const server = { middlewares: { use: (h: any) => (handler = h) } };
|
|
36
|
+
(plugin.configureServer as any)(server);
|
|
37
|
+
if (!handler) throw new Error('plugin registered no middleware');
|
|
38
|
+
return handler;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeRes() {
|
|
42
|
+
return {
|
|
43
|
+
statusCode: 200,
|
|
44
|
+
headers: {} as Record<string, string>,
|
|
45
|
+
body: undefined as Buffer | string | undefined,
|
|
46
|
+
setHeader(name: string, value: string) {
|
|
47
|
+
this.headers[name.toLowerCase()] = value;
|
|
48
|
+
},
|
|
49
|
+
end(chunk?: Buffer | string) {
|
|
50
|
+
this.body = chunk;
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('tina() integration — astro:config:setup', () => {
|
|
56
|
+
it('wires the middleware without writing to the source tree', () => {
|
|
57
|
+
const { addMiddleware, plugins } = runConfigSetup();
|
|
58
|
+
|
|
59
|
+
expect(addMiddleware).toHaveBeenCalledWith({
|
|
60
|
+
entrypoint: '@tinacms/astro/middleware',
|
|
61
|
+
order: 'pre',
|
|
62
|
+
});
|
|
63
|
+
// A dev-only Vite plugin handles the bridge; nothing is staged on disk.
|
|
64
|
+
const plugin = plugins.find((p) => p.name === '@tinacms/astro:bridge-dev');
|
|
65
|
+
expect(plugin).toBeDefined();
|
|
66
|
+
expect(plugin?.apply).toBe('serve');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('tina() integration — bridge dev plugin', () => {
|
|
71
|
+
it('serves the bridge bundle at /admin/bridge.js', () => {
|
|
72
|
+
const { plugins } = runConfigSetup();
|
|
73
|
+
const plugin = plugins.find((p) => p.name === '@tinacms/astro:bridge-dev')!;
|
|
74
|
+
const handler = devHandler(plugin);
|
|
75
|
+
|
|
76
|
+
const res = makeRes();
|
|
77
|
+
const next = vi.fn();
|
|
78
|
+
handler({ url: '/admin/bridge.js' }, res, next);
|
|
79
|
+
|
|
80
|
+
expect(next).not.toHaveBeenCalled();
|
|
81
|
+
expect(res.headers['content-type']).toBe('text/javascript');
|
|
82
|
+
expect((res.body as Buffer).length).toBeGreaterThan(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('passes other requests through', () => {
|
|
86
|
+
const { plugins } = runConfigSetup();
|
|
87
|
+
const plugin = plugins.find((p) => p.name === '@tinacms/astro:bridge-dev')!;
|
|
88
|
+
const handler = devHandler(plugin);
|
|
89
|
+
|
|
90
|
+
const res = makeRes();
|
|
91
|
+
const next = vi.fn();
|
|
92
|
+
handler({ url: '/some/page' }, res, next);
|
|
93
|
+
|
|
94
|
+
expect(next).toHaveBeenCalledOnce();
|
|
95
|
+
expect(res.body).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('tina() integration — astro:build:done', () => {
|
|
100
|
+
it('emits bridge.js into the client output dir', () => {
|
|
101
|
+
const clientDir = mkdtempSync(join(tmpdir(), 'tina-client-'));
|
|
102
|
+
const { integration } = runConfigSetup();
|
|
103
|
+
const logger = { warn: vi.fn(), info: vi.fn() };
|
|
104
|
+
|
|
105
|
+
(
|
|
106
|
+
integration.hooks['astro:config:done'] as NonNullable<
|
|
107
|
+
Hooks['astro:config:done']
|
|
108
|
+
>
|
|
109
|
+
)({
|
|
110
|
+
config: { build: { client: pathToFileURL(clientDir + sep) } },
|
|
111
|
+
} as unknown as ConfigDoneArg);
|
|
112
|
+
|
|
113
|
+
(
|
|
114
|
+
integration.hooks['astro:build:done'] as NonNullable<
|
|
115
|
+
Hooks['astro:build:done']
|
|
116
|
+
>
|
|
117
|
+
)({ logger } as unknown as BuildDoneArg);
|
|
118
|
+
|
|
119
|
+
const bridgePath = join(clientDir, 'admin', 'bridge.js');
|
|
120
|
+
expect(existsSync(bridgePath)).toBe(true);
|
|
121
|
+
expect(readFileSync(bridgePath, 'utf-8').length).toBeGreaterThan(0);
|
|
122
|
+
expect(logger.warn).not.toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { PREVIEW_CONTENT_TYPE, PRIME_HEADER } from '@tinacms/bridge/preview';
|
|
2
|
+
import type { APIContext } from 'astro';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { requestWithMetadata } from '../data';
|
|
5
|
+
import { experimental_createIslandRoute } from '../island-route';
|
|
6
|
+
import IslandStub from './IslandStub.astro';
|
|
7
|
+
|
|
8
|
+
const route = experimental_createIslandRoute({
|
|
9
|
+
post: {
|
|
10
|
+
fetch: () =>
|
|
11
|
+
requestWithMetadata(
|
|
12
|
+
Promise.resolve({
|
|
13
|
+
data: { post: { title: 'Hi' } },
|
|
14
|
+
query: 'query Post',
|
|
15
|
+
variables: { relativePath: 'hello.md' },
|
|
16
|
+
}),
|
|
17
|
+
{ priority: 'primary' }
|
|
18
|
+
),
|
|
19
|
+
component: IslandStub,
|
|
20
|
+
wrapper: { tag: 'article', className: 'prose' },
|
|
21
|
+
propsFromData: (data) => ({ value: data }),
|
|
22
|
+
},
|
|
23
|
+
global: {
|
|
24
|
+
fetch: () =>
|
|
25
|
+
requestWithMetadata(
|
|
26
|
+
Promise.resolve({
|
|
27
|
+
data: { config: { theme: 'dark' } },
|
|
28
|
+
query: 'query Global',
|
|
29
|
+
variables: {},
|
|
30
|
+
})
|
|
31
|
+
),
|
|
32
|
+
component: IslandStub,
|
|
33
|
+
wrapper: { tag: 'header' },
|
|
34
|
+
propsFromData: (data) => ({ value: data }),
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function call(
|
|
39
|
+
headers: Record<string, string>,
|
|
40
|
+
name = 'post'
|
|
41
|
+
): Promise<Response> {
|
|
42
|
+
const url = new URL(`http://localhost/tina-island/${name}`);
|
|
43
|
+
const request = new Request(url, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { 'content-type': PREVIEW_CONTENT_TYPE, ...headers },
|
|
46
|
+
body: '{}',
|
|
47
|
+
});
|
|
48
|
+
return Promise.resolve(
|
|
49
|
+
route({ params: { name }, request, url } as unknown as APIContext)
|
|
50
|
+
) as Promise<Response>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('experimental_createIslandRoute', () => {
|
|
54
|
+
it('renders the wrapped island region', async () => {
|
|
55
|
+
const res = await call({});
|
|
56
|
+
expect(res.status).toBe(200);
|
|
57
|
+
const html = await res.text();
|
|
58
|
+
expect(html).toContain(
|
|
59
|
+
'<article class="prose" data-tina-island="/tina-island/post">'
|
|
60
|
+
);
|
|
61
|
+
expect(html).toContain('stub');
|
|
62
|
+
expect(html).not.toContain('data-tina-form=');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('prepends the collected form payloads on a primed request', async () => {
|
|
66
|
+
const res = await call({ [PRIME_HEADER]: '1' });
|
|
67
|
+
expect(res.status).toBe(200);
|
|
68
|
+
const html = await res.text();
|
|
69
|
+
expect(html).toContain('data-tina-form=');
|
|
70
|
+
// The payload carries the query/variables the loader used.
|
|
71
|
+
expect(html).toContain('query Post');
|
|
72
|
+
expect(html).toContain('hello.md');
|
|
73
|
+
// The region HTML still follows the payload divs.
|
|
74
|
+
expect(html).toContain('data-tina-island="/tina-island/post"');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('emits `data-tina-primary` only on payloads that set `priority: "primary"`', async () => {
|
|
78
|
+
// The `post` loader passes `{ priority: 'primary' }`; the `global`
|
|
79
|
+
// loader does not. Each island route call renders its own forms
|
|
80
|
+
// independently, so a positional marker (`i === 0`) would tag the
|
|
81
|
+
// first form of *every* island — on a page that primes
|
|
82
|
+
// `[page, global-header, global-footer]` the bridge would see three
|
|
83
|
+
// competing primaries and the first in DOM order (usually a layout
|
|
84
|
+
// global) would win the retry loop.
|
|
85
|
+
const postHtml = await (await call({ [PRIME_HEADER]: '1' }, 'post')).text();
|
|
86
|
+
expect(postHtml).toContain('data-tina-form=');
|
|
87
|
+
expect(postHtml).toContain('data-tina-primary');
|
|
88
|
+
|
|
89
|
+
const globalHtml = await (
|
|
90
|
+
await call({ [PRIME_HEADER]: '1' }, 'global')
|
|
91
|
+
).text();
|
|
92
|
+
expect(globalHtml).toContain('data-tina-form=');
|
|
93
|
+
expect(globalHtml).not.toContain('data-tina-primary');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('rejects non-preview requests', async () => {
|
|
97
|
+
const url = new URL('http://localhost/tina-island/post');
|
|
98
|
+
const res = (await route({
|
|
99
|
+
params: { name: 'post' },
|
|
100
|
+
request: new Request(url, { method: 'POST' }),
|
|
101
|
+
url,
|
|
102
|
+
} as unknown as APIContext)) as Response;
|
|
103
|
+
expect(res.status).toBe(404);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('404s an unknown island', async () => {
|
|
107
|
+
const url = new URL('http://localhost/tina-island/nope');
|
|
108
|
+
const res = (await route({
|
|
109
|
+
params: { name: 'nope' },
|
|
110
|
+
request: new Request(url, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'content-type': PREVIEW_CONTENT_TYPE },
|
|
113
|
+
body: '{}',
|
|
114
|
+
}),
|
|
115
|
+
url,
|
|
116
|
+
} as unknown as APIContext)) as Response;
|
|
117
|
+
expect(res.status).toBe(404);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { APIContext, MiddlewareNext } from 'astro';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { requestWithMetadata } from '../data';
|
|
4
|
+
import { onRequest } from '../middleware';
|
|
5
|
+
|
|
6
|
+
function makeContext(
|
|
7
|
+
overrides: Partial<APIContext> & { request?: unknown }
|
|
8
|
+
): APIContext {
|
|
9
|
+
return {
|
|
10
|
+
locals: {},
|
|
11
|
+
request: new Request('https://example.com/'),
|
|
12
|
+
isPrerendered: false,
|
|
13
|
+
...overrides,
|
|
14
|
+
} as unknown as APIContext;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const passThrough: MiddlewareNext = () =>
|
|
18
|
+
Promise.resolve(
|
|
19
|
+
new Response('<html><head></head><body>ok</body></html>', {
|
|
20
|
+
headers: { 'content-type': 'text/html' },
|
|
21
|
+
})
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
describe('onRequest', () => {
|
|
25
|
+
it('short-circuits prerendered routes without touching request headers', async () => {
|
|
26
|
+
// A synthetic build-time Request whose header access would emit Astro's
|
|
27
|
+
// `Astro.request.headers` warning — modeled here as a throw.
|
|
28
|
+
const request = {
|
|
29
|
+
url: 'https://example.com/',
|
|
30
|
+
headers: {
|
|
31
|
+
get() {
|
|
32
|
+
throw new Error(
|
|
33
|
+
'request headers must not be read while prerendering'
|
|
34
|
+
);
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const context = makeContext({ isPrerendered: true, request });
|
|
39
|
+
const next = vi.fn(passThrough);
|
|
40
|
+
|
|
41
|
+
const response = await onRequest(context, next);
|
|
42
|
+
|
|
43
|
+
expect(next).toHaveBeenCalledOnce();
|
|
44
|
+
expect((context.locals as { tinaEdit?: boolean }).tinaEdit).toBe(false);
|
|
45
|
+
expect(await response.text()).toContain('ok');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('resolves edit mode for non-prerendered requests', async () => {
|
|
49
|
+
const context = makeContext({ isPrerendered: false });
|
|
50
|
+
const next = vi.fn(passThrough);
|
|
51
|
+
|
|
52
|
+
await onRequest(context, next);
|
|
53
|
+
|
|
54
|
+
expect(next).toHaveBeenCalledOnce();
|
|
55
|
+
// Plain request — no iframe Sec-Fetch-Dest, no cookie — so not editing.
|
|
56
|
+
expect((context.locals as { tinaEdit?: boolean }).tinaEdit).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('splices `priority: "primary"` form payloads before secondaries', async () => {
|
|
60
|
+
// Edit-mode request — iframe destination plus the edit cookie.
|
|
61
|
+
const request = new Request('https://example.com/', {
|
|
62
|
+
headers: {
|
|
63
|
+
'sec-fetch-dest': 'iframe',
|
|
64
|
+
cookie: '__tina_edit=1',
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
const context = makeContext({ isPrerendered: false, request });
|
|
68
|
+
|
|
69
|
+
// The page calls global first (layout order) but the page-level loader
|
|
70
|
+
// marks its own document primary — the primary should land first in
|
|
71
|
+
// the rendered HTML regardless of call order.
|
|
72
|
+
const next: MiddlewareNext = async () => {
|
|
73
|
+
await requestWithMetadata(
|
|
74
|
+
Promise.resolve({
|
|
75
|
+
data: {},
|
|
76
|
+
query: 'query Global',
|
|
77
|
+
variables: {},
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
await requestWithMetadata(
|
|
81
|
+
Promise.resolve({
|
|
82
|
+
data: {},
|
|
83
|
+
query: 'query Page',
|
|
84
|
+
variables: { slug: 'home' },
|
|
85
|
+
}),
|
|
86
|
+
{ priority: 'primary' }
|
|
87
|
+
);
|
|
88
|
+
return new Response('<html><head></head><body>ok</body></html>', {
|
|
89
|
+
headers: { 'content-type': 'text/html' },
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const response = await onRequest(context, next);
|
|
94
|
+
const html = await response.text();
|
|
95
|
+
|
|
96
|
+
const pageIdx = html.indexOf('query Page');
|
|
97
|
+
const globalIdx = html.indexOf('query Global');
|
|
98
|
+
expect(pageIdx).toBeGreaterThan(-1);
|
|
99
|
+
expect(globalIdx).toBeGreaterThan(-1);
|
|
100
|
+
expect(pageIdx).toBeLessThan(globalIdx);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ViteDevServer } from 'vite';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { tinaAdminDevRedirect } from '../vite';
|
|
4
|
+
|
|
5
|
+
type Handler = (req: any, res: any, next: () => void) => void;
|
|
6
|
+
|
|
7
|
+
function registerHandler(): Handler {
|
|
8
|
+
const plugin = tinaAdminDevRedirect();
|
|
9
|
+
let handler: Handler | undefined;
|
|
10
|
+
const server = {
|
|
11
|
+
middlewares: { use: (h: Handler) => (handler = h) },
|
|
12
|
+
} as unknown as ViteDevServer;
|
|
13
|
+
(plugin.configureServer as any)(server);
|
|
14
|
+
if (!handler) throw new Error('plugin did not register a middleware');
|
|
15
|
+
return handler;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeRes() {
|
|
19
|
+
return {
|
|
20
|
+
statusCode: 200,
|
|
21
|
+
headers: {} as Record<string, string>,
|
|
22
|
+
ended: false,
|
|
23
|
+
setHeader(name: string, value: string) {
|
|
24
|
+
this.headers[name] = value;
|
|
25
|
+
},
|
|
26
|
+
end() {
|
|
27
|
+
this.ended = true;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('tinaAdminDevRedirect', () => {
|
|
33
|
+
it('only applies to the dev server', () => {
|
|
34
|
+
expect(tinaAdminDevRedirect().apply).toBe('serve');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it.each(['/admin', '/admin/', '/admin?foo=bar'])(
|
|
38
|
+
'redirects %s to /admin/index.html',
|
|
39
|
+
(url) => {
|
|
40
|
+
const handler = registerHandler();
|
|
41
|
+
const res = makeRes();
|
|
42
|
+
const next = vi.fn();
|
|
43
|
+
|
|
44
|
+
handler({ url }, res, next);
|
|
45
|
+
|
|
46
|
+
expect(res.statusCode).toBe(302);
|
|
47
|
+
expect(res.headers.Location).toBe('/admin/index.html');
|
|
48
|
+
expect(res.ended).toBe(true);
|
|
49
|
+
expect(next).not.toHaveBeenCalled();
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
it.each(['/admin/index.html', '/admin/foo', '/admins', '/'])(
|
|
54
|
+
'passes %s through untouched',
|
|
55
|
+
(url) => {
|
|
56
|
+
const handler = registerHandler();
|
|
57
|
+
const res = makeRes();
|
|
58
|
+
const next = vi.fn();
|
|
59
|
+
|
|
60
|
+
handler({ url }, res, next);
|
|
61
|
+
|
|
62
|
+
expect(next).toHaveBeenCalledOnce();
|
|
63
|
+
expect(res.ended).toBe(false);
|
|
64
|
+
expect(res.statusCode).toBe(200);
|
|
65
|
+
}
|
|
66
|
+
);
|
|
67
|
+
});
|