@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
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expectTypeOf, it } from 'vitest';
|
|
2
|
+
import type { QueryResult, RequestOptions } from './data';
|
|
3
|
+
import { requestWithMetadata } from './data';
|
|
4
|
+
|
|
5
|
+
interface Post {
|
|
6
|
+
title: string;
|
|
7
|
+
body: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// requestWithMetadata is the one wrapper every Astro page calls around
|
|
11
|
+
// client.queries.<name>(). A regression that widened TData to `unknown`
|
|
12
|
+
// would pass every runtime test, so the guarantee has to be type-level.
|
|
13
|
+
|
|
14
|
+
describe('requestWithMetadata', () => {
|
|
15
|
+
it('threads the query data type through to QueryResult', async () => {
|
|
16
|
+
const result = await requestWithMetadata(
|
|
17
|
+
Promise.resolve({
|
|
18
|
+
data: { title: 't', body: 'b' } satisfies Post,
|
|
19
|
+
query: 'query Post { ... }',
|
|
20
|
+
variables: {},
|
|
21
|
+
})
|
|
22
|
+
);
|
|
23
|
+
expectTypeOf(result).toEqualTypeOf<QueryResult<Post>>();
|
|
24
|
+
expectTypeOf(result.data).toEqualTypeOf<Post>();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('does not widen TData when given an explicit type argument', () => {
|
|
28
|
+
expectTypeOf<
|
|
29
|
+
Awaited<ReturnType<typeof requestWithMetadata<Post>>>
|
|
30
|
+
>().toEqualTypeOf<QueryResult<Post>>();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('accepts a bare (non-promise) client result', () => {
|
|
34
|
+
expectTypeOf(requestWithMetadata).toBeCallableWith({
|
|
35
|
+
data: { title: 't', body: 'b' } satisfies Post,
|
|
36
|
+
query: 'q',
|
|
37
|
+
variables: {},
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('accepts null / undefined sources (static build, no client scope)', () => {
|
|
42
|
+
expectTypeOf(requestWithMetadata).toBeCallableWith(null);
|
|
43
|
+
expectTypeOf(requestWithMetadata).toBeCallableWith(undefined);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('RequestOptions', () => {
|
|
48
|
+
it('priority is the literal "primary" or absent', () => {
|
|
49
|
+
expectTypeOf<RequestOptions['priority']>().toEqualTypeOf<
|
|
50
|
+
'primary' | undefined
|
|
51
|
+
>();
|
|
52
|
+
});
|
|
53
|
+
});
|
package/src/data.ts
CHANGED
|
@@ -1,29 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `requestWithMetadata()` is the only Astro-specific call site needed
|
|
3
|
-
* around the standard `client.queries.<name>(...)` pattern. In production
|
|
4
|
-
* it stamps the result with the metadata `tinaField()` reads to build
|
|
5
|
-
* click-to-focus markers and the `id` the bridge uses to address forms;
|
|
6
|
-
* inside the editor iframe it additionally swaps `data` for the unsaved
|
|
7
|
-
* overlay the bridge has POSTed for that form.
|
|
8
|
-
*
|
|
9
|
-
* The current `Astro.request` is read from AsyncLocalStorage scoped by
|
|
10
|
-
* the middleware the `tina()` integration injects, so the call site stays
|
|
11
|
-
* a single argument:
|
|
12
|
-
*
|
|
13
|
-
* ```ts
|
|
14
|
-
* import client from '../../tina/__generated__/client';
|
|
15
|
-
* import { requestWithMetadata } from '@tinacms/astro';
|
|
16
|
-
*
|
|
17
|
-
* const post = await requestWithMetadata(
|
|
18
|
-
* client.queries.post({ relativePath: `${slug}.md` }),
|
|
19
|
-
* );
|
|
20
|
-
* ```
|
|
21
|
-
*
|
|
22
|
-
* Outside a request scope (static builds, integration not installed) the
|
|
23
|
-
* wrapper falls through to a metadata-stamped pass-through — production
|
|
24
|
-
* output stays correct, just without the live overlay swap that only
|
|
25
|
-
* matters during admin editing.
|
|
26
|
-
*/
|
|
27
1
|
import { addMetadata, hashFromQuery } from '@tinacms/bridge/metadata';
|
|
28
2
|
import { readOverlay } from '@tinacms/bridge/preview';
|
|
29
3
|
import { recordForm } from './internal/forms-store';
|
|
@@ -36,9 +10,6 @@ export interface QueryResult<TData> {
|
|
|
36
10
|
id: string;
|
|
37
11
|
}
|
|
38
12
|
|
|
39
|
-
/** Shape every `client.queries.<name>` returns. Inferring from this lets
|
|
40
|
-
* `requestWithMetadata()` stay framework-agnostic — it doesn't need to
|
|
41
|
-
* know about `PostQuery`, `PageQuery`, etc. */
|
|
42
13
|
type ClientResult<TData> =
|
|
43
14
|
| {
|
|
44
15
|
data: TData;
|
|
@@ -48,17 +19,23 @@ type ClientResult<TData> =
|
|
|
48
19
|
| null
|
|
49
20
|
| undefined;
|
|
50
21
|
|
|
22
|
+
export interface RequestOptions {
|
|
23
|
+
/** Mark the page's own document so the admin opens it on load instead
|
|
24
|
+
* of a layout-level global. Mirrors `useTina`'s
|
|
25
|
+
* `experimental___selectFormByFormId`. */
|
|
26
|
+
priority?: 'primary';
|
|
27
|
+
}
|
|
28
|
+
|
|
51
29
|
export async function requestWithMetadata<TData>(
|
|
52
|
-
source: ClientResult<TData> | Promise<ClientResult<TData
|
|
30
|
+
source: ClientResult<TData> | Promise<ClientResult<TData>>,
|
|
31
|
+
options?: RequestOptions
|
|
53
32
|
): Promise<QueryResult<TData>> {
|
|
54
33
|
let result: ClientResult<TData> = null;
|
|
55
34
|
try {
|
|
56
35
|
result = (await source) ?? null;
|
|
57
36
|
} catch (error) {
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
// — the bridge will populate via overlay. In production, the warning
|
|
61
|
-
// surfaces real misconfigurations.
|
|
37
|
+
// In edit mode a doc the editor is creating won't exist yet; the
|
|
38
|
+
// bridge will populate via overlay. Don't crash the render.
|
|
62
39
|
console.warn('[@tinacms/astro] client query failed', error);
|
|
63
40
|
}
|
|
64
41
|
|
|
@@ -83,14 +60,13 @@ export async function requestWithMetadata<TData>(
|
|
|
83
60
|
id,
|
|
84
61
|
};
|
|
85
62
|
|
|
86
|
-
//
|
|
87
|
-
// can splice the bridge wiring into edit-mode pages without the caller
|
|
88
|
-
// touching their layout. No-ops outside a request scope (static builds).
|
|
63
|
+
// No-op outside a request scope (static builds).
|
|
89
64
|
recordForm({
|
|
90
65
|
id,
|
|
91
66
|
query,
|
|
92
67
|
variables,
|
|
93
68
|
data: enriched.data as unknown as object,
|
|
69
|
+
priority: options?.priority,
|
|
94
70
|
});
|
|
95
71
|
|
|
96
72
|
return enriched;
|
package/src/integration.ts
CHANGED
|
@@ -1,49 +1,94 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
*
|
|
8
|
-
* ```ts
|
|
9
|
-
* // astro.config.mjs
|
|
10
|
-
* import { defineConfig } from 'astro/config';
|
|
11
|
-
* import tina from '@tinacms/astro/integration';
|
|
12
|
-
*
|
|
13
|
-
* export default defineConfig({
|
|
14
|
-
* integrations: [tina()],
|
|
15
|
-
* });
|
|
16
|
-
* ```
|
|
17
|
-
*/
|
|
18
|
-
import type { AstroIntegration } from 'astro';
|
|
1
|
+
import { copyFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import type { AstroIntegration, AstroIntegrationLogger } from 'astro';
|
|
6
|
+
import type { Plugin as VitePlugin } from 'vite';
|
|
19
7
|
|
|
20
8
|
export interface TinaIntegrationOptions {
|
|
21
|
-
/** Override the middleware ordering relative to other integrations.
|
|
22
|
-
* Defaults to `'pre'` so `Astro.locals.tinaEdit` is populated before
|
|
23
|
-
* user middleware sees the request. */
|
|
24
9
|
middlewareOrder?: 'pre' | 'post';
|
|
25
10
|
}
|
|
26
11
|
|
|
12
|
+
/** Where the injected bridge bootstrap imports the bridge bundle from. */
|
|
13
|
+
const BRIDGE_ROUTE = '/admin/bridge.js';
|
|
14
|
+
|
|
27
15
|
export default function tina(
|
|
28
16
|
options: TinaIntegrationOptions = {}
|
|
29
17
|
): AstroIntegration {
|
|
30
18
|
const { middlewareOrder = 'pre' } = options;
|
|
19
|
+
// Resolved client output dir, captured at config:done and consumed at
|
|
20
|
+
// build:done (only then is the final output location known).
|
|
21
|
+
let clientDir: URL | undefined;
|
|
22
|
+
|
|
31
23
|
return {
|
|
32
24
|
name: '@tinacms/astro',
|
|
33
25
|
hooks: {
|
|
34
|
-
'astro:config:setup': ({ addMiddleware,
|
|
26
|
+
'astro:config:setup': ({ addMiddleware, updateConfig }) => {
|
|
35
27
|
addMiddleware({
|
|
36
28
|
entrypoint: '@tinacms/astro/middleware',
|
|
37
29
|
order: middlewareOrder,
|
|
38
30
|
});
|
|
39
|
-
//
|
|
40
|
-
// into
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
31
|
+
// Dev: serve the bridge straight from the package rather than writing
|
|
32
|
+
// it into the user's source tree — config-time writes churn
|
|
33
|
+
// public/admin on every `astro dev`/`astro build`, break on read-only
|
|
34
|
+
// or sandboxed filesystems, and can race `tinacms build`.
|
|
35
|
+
updateConfig({ vite: { plugins: [bridgeDevPlugin()] } });
|
|
36
|
+
},
|
|
37
|
+
'astro:config:done': ({ config }) => {
|
|
38
|
+
clientDir = config.build.client;
|
|
46
39
|
},
|
|
40
|
+
'astro:build:done': ({ logger }) => {
|
|
41
|
+
// Build: emit the bridge next to the admin SPA in the *output* tree.
|
|
42
|
+
// `build.client` is where `public/` is copied to and what every
|
|
43
|
+
// adapter serves statically, so the bridge ships as a static asset
|
|
44
|
+
// (some adapters won't emit injected on-demand routes — e.g.
|
|
45
|
+
// @astrojs/vercel in static mode) without touching the source tree.
|
|
46
|
+
if (!clientDir) return;
|
|
47
|
+
emitBridgeAsset(fileURLToPath(new URL('admin/', clientDir)), logger);
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveBridge(): string {
|
|
54
|
+
return createRequire(import.meta.url).resolve('@tinacms/bridge');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Dev-only Vite plugin that answers `/admin/bridge.js` from the installed
|
|
58
|
+
// bridge package. The bridge never varies per request, so a single readFile
|
|
59
|
+
// per request is fine and nothing is persisted to disk.
|
|
60
|
+
function bridgeDevPlugin(): VitePlugin {
|
|
61
|
+
return {
|
|
62
|
+
name: '@tinacms/astro:bridge-dev',
|
|
63
|
+
apply: 'serve',
|
|
64
|
+
configureServer(server) {
|
|
65
|
+
server.middlewares.use((req, res, next) => {
|
|
66
|
+
const path = (req.url || '').split('?')[0];
|
|
67
|
+
if (path !== BRIDGE_ROUTE) {
|
|
68
|
+
next();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const body = readFileSync(resolveBridge());
|
|
73
|
+
res.setHeader('Content-Type', 'text/javascript');
|
|
74
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
75
|
+
res.end(body);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
res.statusCode = 500;
|
|
78
|
+
res.end(`/* @tinacms/astro: bridge unavailable: ${error} */`);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
47
81
|
},
|
|
48
82
|
};
|
|
49
83
|
}
|
|
84
|
+
|
|
85
|
+
function emitBridgeAsset(adminDir: string, logger: AstroIntegrationLogger) {
|
|
86
|
+
try {
|
|
87
|
+
mkdirSync(adminDir, { recursive: true });
|
|
88
|
+
copyFileSync(resolveBridge(), join(adminDir, 'bridge.js'));
|
|
89
|
+
} catch (error) {
|
|
90
|
+
logger.warn(
|
|
91
|
+
`could not emit admin/bridge.js — visual editing will not load: ${error}`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve `PUBLIC_TINA_ADMIN_ORIGIN` (comma-separated allowed) from
|
|
3
|
+
* Astro's `import.meta.env`. Returns `null` when unset — the bridge then
|
|
4
|
+
* falls back to `window.location.origin`.
|
|
5
|
+
*/
|
|
6
|
+
export function adminOrigins(): string[] | null {
|
|
7
|
+
const env = (
|
|
8
|
+
import.meta as ImportMeta & {
|
|
9
|
+
env?: Record<string, string | undefined>;
|
|
10
|
+
}
|
|
11
|
+
).env;
|
|
12
|
+
const raw = env?.PUBLIC_TINA_ADMIN_ORIGIN;
|
|
13
|
+
if (!raw) return null;
|
|
14
|
+
const origins = raw
|
|
15
|
+
.split(',')
|
|
16
|
+
.map((s) => s.trim())
|
|
17
|
+
.filter(Boolean);
|
|
18
|
+
return origins.length > 0 ? origins : null;
|
|
19
|
+
}
|
|
@@ -1,26 +1,16 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Per-request collector for `tina()` results. Each call pushes its
|
|
3
|
-
* `{id, query, variables, data}` payload here so the integration's
|
|
4
|
-
* middleware can read the full list at response time and splice the
|
|
5
|
-
* bridge wiring into edit-mode pages — the user never writes a `forms`
|
|
6
|
-
* prop or imports a wiring component.
|
|
7
|
-
*
|
|
8
|
-
* Initialised by the middleware the `tina()` integration injects.
|
|
9
|
-
*
|
|
10
|
-
* The store is keyed by a `Symbol.for(...)` slot on `globalThis` so all
|
|
11
|
-
* bundle copies of this module (esbuild inlines it into each entry that
|
|
12
|
-
* imports it) share the same instance — without that, the middleware
|
|
13
|
-
* would `.run()` one ALS while `tina()` reads from a different one.
|
|
14
|
-
*/
|
|
15
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { escapeAttr } from './escape';
|
|
16
3
|
|
|
17
4
|
export interface CollectedForm {
|
|
18
5
|
id: string;
|
|
19
6
|
query: string;
|
|
20
7
|
variables: object;
|
|
21
8
|
data: object;
|
|
9
|
+
priority?: 'primary';
|
|
22
10
|
}
|
|
23
11
|
|
|
12
|
+
// Symbol slot on globalThis so bundle copies (esbuild inlines this module
|
|
13
|
+
// into each entry that imports it) share one ALS instance.
|
|
24
14
|
const STORE_KEY = Symbol.for('@tinacms/astro/forms-store');
|
|
25
15
|
|
|
26
16
|
type Slot = { [STORE_KEY]?: AsyncLocalStorage<CollectedForm[]> };
|
|
@@ -33,9 +23,28 @@ export const formsStore: AsyncLocalStorage<CollectedForm[]> = (slot[
|
|
|
33
23
|
export function recordForm(form: CollectedForm): void {
|
|
34
24
|
const list = formsStore.getStore();
|
|
35
25
|
if (!list) return;
|
|
36
|
-
// Same id can appear
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
if (
|
|
26
|
+
// Same id can appear twice (layout + page both fetch the global). Dedup
|
|
27
|
+
// and upgrade to `primary` if a later call asserts it.
|
|
28
|
+
const existing = list.find((entry) => entry.id === form.id);
|
|
29
|
+
if (existing) {
|
|
30
|
+
if (form.priority === 'primary') existing.priority = 'primary';
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
40
33
|
list.push(form);
|
|
41
34
|
}
|
|
35
|
+
|
|
36
|
+
export function sortByPriority(forms: CollectedForm[]): CollectedForm[] {
|
|
37
|
+
return [...forms].sort(
|
|
38
|
+
(a, b) =>
|
|
39
|
+
(a.priority === 'primary' ? 0 : 1) - (b.priority === 'primary' ? 0 : 1)
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function renderFormPayloadDiv(
|
|
44
|
+
form: CollectedForm,
|
|
45
|
+
primary: boolean
|
|
46
|
+
): string {
|
|
47
|
+
return `<div data-tina-form="${escapeAttr(JSON.stringify(form))}"${
|
|
48
|
+
primary ? ' data-tina-primary' : ''
|
|
49
|
+
} hidden></div>`;
|
|
50
|
+
}
|
package/src/island-route.ts
CHANGED
|
@@ -1,33 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* `islands` describes one editable region: how to load its data, which
|
|
5
|
-
* Astro component to render, and the wrapper element the page-side
|
|
6
|
-
* `<div data-tina-island>` is expected to swap.
|
|
7
|
-
*
|
|
8
|
-
* @experimental
|
|
9
|
-
*
|
|
10
|
-
* Built on Astro's `experimental_AstroContainer`, which is itself
|
|
11
|
-
* experimental — Astro may break the underlying API in any minor or patch
|
|
12
|
-
* release. The shape of `createIslandRoute` is similarly experimental and
|
|
13
|
-
* will graduate once the container API stabilises.
|
|
14
|
-
*
|
|
15
|
-
* Usage:
|
|
16
|
-
*
|
|
17
|
-
* ```ts
|
|
18
|
-
* // src/pages/tina-island/[name].ts
|
|
19
|
-
* import { experimental_createIslandRoute } from '@tinacms/astro/experimental';
|
|
20
|
-
* import { islands } from '../../lib/islands';
|
|
21
|
-
*
|
|
22
|
-
* export const prerender = false;
|
|
23
|
-
* export const ALL = experimental_createIslandRoute(islands);
|
|
24
|
-
* ```
|
|
2
|
+
* @experimental Built on Astro's `experimental_AstroContainer`. Both APIs
|
|
3
|
+
* may break in any Astro minor/patch.
|
|
25
4
|
*/
|
|
5
|
+
import { PREVIEW_CONTENT_TYPE, PRIME_HEADER } from '@tinacms/bridge/preview';
|
|
26
6
|
import type { APIRoute } from 'astro';
|
|
27
7
|
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
|
|
28
8
|
import type { AstroComponentFactory } from 'astro/runtime/server/index.js';
|
|
29
|
-
import { PREVIEW_CONTENT_TYPE } from '@tinacms/bridge/preview';
|
|
30
9
|
import { escapeAttr } from './internal/escape';
|
|
10
|
+
import {
|
|
11
|
+
type CollectedForm,
|
|
12
|
+
formsStore,
|
|
13
|
+
renderFormPayloadDiv,
|
|
14
|
+
sortByPriority,
|
|
15
|
+
} from './internal/forms-store';
|
|
16
|
+
import { requestStore } from './internal/request-context';
|
|
31
17
|
|
|
32
18
|
export interface IslandWrapper {
|
|
33
19
|
tag: string;
|
|
@@ -62,13 +48,23 @@ export function experimental_createIslandRoute(
|
|
|
62
48
|
return new Response(`Unknown island "${params.name}"`, { status: 404 });
|
|
63
49
|
}
|
|
64
50
|
|
|
51
|
+
const priming = request.headers.get(PRIME_HEADER) !== null;
|
|
52
|
+
|
|
65
53
|
try {
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
54
|
+
const forms: CollectedForm[] = [];
|
|
55
|
+
const html = await requestStore.run(request, () =>
|
|
56
|
+
formsStore.run(forms, async () => {
|
|
57
|
+
const data = await island.fetch(request, url.searchParams);
|
|
58
|
+
const container = await AstroContainer.create();
|
|
59
|
+
return container.renderToString(island.component, {
|
|
60
|
+
props: island.propsFromData(data, url.searchParams),
|
|
61
|
+
});
|
|
62
|
+
})
|
|
63
|
+
);
|
|
64
|
+
const body =
|
|
65
|
+
(priming ? renderFormPayloads(forms) : '') +
|
|
66
|
+
wrapIsland(html, island.wrapper, url);
|
|
67
|
+
return new Response(body, {
|
|
72
68
|
headers: {
|
|
73
69
|
'Content-Type': 'text/html; charset=utf-8',
|
|
74
70
|
'Cache-Control': 'no-store',
|
|
@@ -80,12 +76,8 @@ export function experimental_createIslandRoute(
|
|
|
80
76
|
};
|
|
81
77
|
}
|
|
82
78
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
* body. The bridge always issues a same-origin POST with the Tina-preview
|
|
86
|
-
* content-type; production traffic never matches all three signals so it
|
|
87
|
-
* can never reach the renderer.
|
|
88
|
-
*/
|
|
79
|
+
// Bridge issues a same-origin POST with the Tina-preview content-type;
|
|
80
|
+
// production traffic can't match all three signals.
|
|
89
81
|
function rejectIfUnsafe(request: Request): Response | null {
|
|
90
82
|
if (request.method !== 'POST') {
|
|
91
83
|
return new Response('Method Not Allowed', { status: 405 });
|
|
@@ -94,13 +86,20 @@ function rejectIfUnsafe(request: Request): Response | null {
|
|
|
94
86
|
if (!contentType.includes(PREVIEW_CONTENT_TYPE)) {
|
|
95
87
|
return new Response('Not Found', { status: 404 });
|
|
96
88
|
}
|
|
97
|
-
|
|
98
|
-
if (fetchSite === 'cross-site' || fetchSite === 'cross-origin') {
|
|
89
|
+
if (request.headers.get('sec-fetch-site') === 'cross-site') {
|
|
99
90
|
return new Response('Forbidden', { status: 403 });
|
|
100
91
|
}
|
|
101
92
|
return null;
|
|
102
93
|
}
|
|
103
94
|
|
|
95
|
+
// Keyed on the explicit `primary` flag (not position): each island route
|
|
96
|
+
// call is independent, so position would tag every island's first form.
|
|
97
|
+
function renderFormPayloads(forms: CollectedForm[]): string {
|
|
98
|
+
return sortByPriority(forms)
|
|
99
|
+
.map((form) => renderFormPayloadDiv(form, form.priority === 'primary'))
|
|
100
|
+
.join('');
|
|
101
|
+
}
|
|
102
|
+
|
|
104
103
|
function wrapIsland(html: string, wrapper: IslandWrapper, url: URL): string {
|
|
105
104
|
const cls = wrapper.className
|
|
106
105
|
? ` class="${escapeAttr(wrapper.className)}"`
|
package/src/middleware.ts
CHANGED
|
@@ -1,29 +1,24 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Astro middleware injected by the `tina()` integration.
|
|
3
|
-
*
|
|
4
|
-
* - Resolves `isEditMode(request)` once and stashes it on
|
|
5
|
-
* `context.locals.tinaEdit` so pages and components can branch on edit
|
|
6
|
-
* context without re-parsing headers.
|
|
7
|
-
* - Scopes the request and a per-request forms collector via
|
|
8
|
-
* AsyncLocalStorage so `tina()` reads them implicitly — the caller
|
|
9
|
-
* never threads `Astro.request` through their loaders.
|
|
10
|
-
* - In edit mode only, splices `<div data-tina-form>` payloads and a
|
|
11
|
-
* `<script>` that loads `/_tina/bridge.js` before `</head>`. The user
|
|
12
|
-
* writes nothing in their layout, and production HTML is byte-
|
|
13
|
-
* identical to a Tina-free Astro app.
|
|
14
|
-
* - In edit mode only, refreshes the `__tina_edit` cookie so the session
|
|
15
|
-
* survives in-iframe link clicks (whose Referer is the previous
|
|
16
|
-
* preview page, not `/admin/`).
|
|
17
|
-
*/
|
|
18
1
|
import type { MiddlewareHandler } from 'astro';
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
2
|
+
import { adminOrigins } from './internal/admin-origin';
|
|
3
|
+
import {
|
|
4
|
+
type CollectedForm,
|
|
5
|
+
formsStore,
|
|
6
|
+
renderFormPayloadDiv,
|
|
7
|
+
sortByPriority,
|
|
8
|
+
} from './internal/forms-store';
|
|
21
9
|
import { requestStore } from './internal/request-context';
|
|
22
10
|
import { EDIT_COOKIE_HEADER, isEditMode } from './is-edit-mode';
|
|
23
11
|
|
|
24
12
|
const HEAD_CLOSE = '</head>';
|
|
25
13
|
|
|
26
14
|
export const onRequest: MiddlewareHandler = (context, next) => {
|
|
15
|
+
// Prerendered routes can never be in edit mode; reading their synthetic
|
|
16
|
+
// build-time headers would only emit Astro's request.headers warning.
|
|
17
|
+
if (context.isPrerendered) {
|
|
18
|
+
(context.locals as { tinaEdit?: boolean }).tinaEdit = false;
|
|
19
|
+
return next();
|
|
20
|
+
}
|
|
21
|
+
|
|
27
22
|
const editing = isEditMode(context.request);
|
|
28
23
|
(context.locals as { tinaEdit?: boolean }).tinaEdit = editing;
|
|
29
24
|
|
|
@@ -73,11 +68,10 @@ function editModeInit(response: Response): ResponseInit {
|
|
|
73
68
|
}
|
|
74
69
|
|
|
75
70
|
function renderInjection(forms: CollectedForm[]): string {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
)
|
|
71
|
+
// No explicit priority => sort is a no-op and the first form (page
|
|
72
|
+
// frontmatter runs before its layout's) wins as primary.
|
|
73
|
+
const formDivs = sortByPriority(forms)
|
|
74
|
+
.map((form, i) => renderFormPayloadDiv(form, i === 0))
|
|
81
75
|
.join('');
|
|
82
76
|
return formDivs + bridgeScript();
|
|
83
77
|
}
|
|
@@ -87,32 +81,9 @@ function bridgeScript(): string {
|
|
|
87
81
|
const initArg = origins ? `{adminOrigin:${JSON.stringify(origins)}}` : '';
|
|
88
82
|
return (
|
|
89
83
|
`<script type="module">` +
|
|
90
|
-
`import{init,refreshForms}from"/
|
|
84
|
+
`import{init,refreshForms}from"/admin/bridge.js";` +
|
|
91
85
|
`init(${initArg});` +
|
|
92
86
|
`document.addEventListener("astro:page-load",refreshForms);` +
|
|
93
87
|
`</script>`
|
|
94
88
|
);
|
|
95
89
|
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Read `PUBLIC_TINA_ADMIN_ORIGIN` (comma-separated for multi-origin setups)
|
|
99
|
-
* from Astro's `import.meta.env`. Returns null when unset so `bridge.init()`
|
|
100
|
-
* falls back to `window.location.origin` — the common same-host case.
|
|
101
|
-
*
|
|
102
|
-
* `import.meta.env` is cast inline because the package ships no `env.d.ts`
|
|
103
|
-
* to keep the public type surface free of Vite/Astro client-types coupling.
|
|
104
|
-
*/
|
|
105
|
-
function adminOrigins(): string[] | null {
|
|
106
|
-
const env = (
|
|
107
|
-
import.meta as ImportMeta & {
|
|
108
|
-
env?: Record<string, string | undefined>;
|
|
109
|
-
}
|
|
110
|
-
).env;
|
|
111
|
-
const raw = env?.PUBLIC_TINA_ADMIN_ORIGIN;
|
|
112
|
-
if (!raw) return null;
|
|
113
|
-
const origins = raw
|
|
114
|
-
.split(',')
|
|
115
|
-
.map((s) => s.trim())
|
|
116
|
-
.filter(Boolean);
|
|
117
|
-
return origins.length > 0 ? origins : null;
|
|
118
|
-
}
|
package/src/vite.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dev-only Vite plugin that redirects `/admin` (and `/admin/`) to
|
|
5
|
+
* `/admin/index.html`.
|
|
6
|
+
*
|
|
7
|
+
* In `astro dev`, the admin SPA is served straight from `public/admin/` and
|
|
8
|
+
* Vite does not resolve a directory index for it, so a bare `/admin` request
|
|
9
|
+
* 404s. This plugin makes `/admin` land on the SPA the same way it does in a
|
|
10
|
+
* production build. It only applies to the dev server (`apply: 'serve'`); a
|
|
11
|
+
* built site serves `public/admin/index.html` itself.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* // astro.config.mjs
|
|
15
|
+
* import { tinaAdminDevRedirect } from '@tinacms/astro/vite';
|
|
16
|
+
*
|
|
17
|
+
* export default defineConfig({
|
|
18
|
+
* vite: { plugins: [tinaAdminDevRedirect()] },
|
|
19
|
+
* });
|
|
20
|
+
*/
|
|
21
|
+
export function tinaAdminDevRedirect(): Plugin {
|
|
22
|
+
return {
|
|
23
|
+
name: 'tina-admin-dev-redirect',
|
|
24
|
+
apply: 'serve',
|
|
25
|
+
configureServer(server) {
|
|
26
|
+
server.middlewares.use((req, res, next) => {
|
|
27
|
+
const path = (req.url || '').split('?')[0];
|
|
28
|
+
if (path === '/admin' || path === '/admin/') {
|
|
29
|
+
res.statusCode = 302;
|
|
30
|
+
res.setHeader('Location', '/admin/index.html');
|
|
31
|
+
res.end();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
next();
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default tinaAdminDevRedirect;
|
package/dist/bridge-route.d.ts
DELETED
package/dist/bridge-route.js
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
// src/bridge-route.ts
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import { createRequire } from "node:module";
|
|
4
|
-
var require2 = createRequire(import.meta.url);
|
|
5
|
-
var cached;
|
|
6
|
-
function loadBridge() {
|
|
7
|
-
if (cached !== void 0) return cached;
|
|
8
|
-
const bridgePath = require2.resolve("@tinacms/bridge");
|
|
9
|
-
cached = readFileSync(bridgePath, "utf-8");
|
|
10
|
-
return cached;
|
|
11
|
-
}
|
|
12
|
-
var prerender = false;
|
|
13
|
-
var GET = () => new Response(loadBridge(), {
|
|
14
|
-
headers: {
|
|
15
|
-
"Content-Type": "application/javascript; charset=utf-8",
|
|
16
|
-
"Cache-Control": "public, max-age=31536000, immutable"
|
|
17
|
-
}
|
|
18
|
-
});
|
|
19
|
-
export {
|
|
20
|
-
GET,
|
|
21
|
-
prerender
|
|
22
|
-
};
|
package/src/bridge-route.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Astro endpoint injected by the `tina()` integration. Serves the bridge
|
|
3
|
-
* as a single self-contained ESM bundle at `/_tina/bridge.js`. Loaded by
|
|
4
|
-
* the inline `<script type="module">` the middleware splices into edit-
|
|
5
|
-
* mode pages — production visitors never reach this URL.
|
|
6
|
-
*
|
|
7
|
-
* `@tinacms/bridge`'s build already emits a fully bundled `dist/index.js`
|
|
8
|
-
* (no relative imports remain), so we stream it back as-is and let the
|
|
9
|
-
* browser cache it immutably.
|
|
10
|
-
*/
|
|
11
|
-
import { readFileSync } from 'node:fs';
|
|
12
|
-
import { createRequire } from 'node:module';
|
|
13
|
-
import type { APIRoute } from 'astro';
|
|
14
|
-
|
|
15
|
-
const require = createRequire(import.meta.url);
|
|
16
|
-
let cached: string | undefined;
|
|
17
|
-
|
|
18
|
-
function loadBridge(): string {
|
|
19
|
-
if (cached !== undefined) return cached;
|
|
20
|
-
const bridgePath = require.resolve('@tinacms/bridge');
|
|
21
|
-
cached = readFileSync(bridgePath, 'utf-8');
|
|
22
|
-
return cached;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export const prerender = false;
|
|
26
|
-
|
|
27
|
-
export const GET: APIRoute = () =>
|
|
28
|
-
new Response(loadBridge(), {
|
|
29
|
-
headers: {
|
|
30
|
-
'Content-Type': 'application/javascript; charset=utf-8',
|
|
31
|
-
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
32
|
-
},
|
|
33
|
-
});
|