@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.
@@ -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
- // Disk-fetch failures are normal in edit mode (a doc the editor is
59
- // creating doesn't exist yet) and should never crash the page render
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
- // Append to the per-request forms list so the integration's middleware
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;
@@ -1,49 +1,94 @@
1
- /**
2
- * Tina Astro integration. Wires the middleware that exposes
3
- * `Astro.locals.tinaEdit` so pages and components can branch on edit
4
- * context without writing `src/middleware.ts` themselves.
5
- *
6
- * Usage:
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, injectRoute }) => {
26
+ 'astro:config:setup': ({ addMiddleware, updateConfig }) => {
35
27
  addMiddleware({
36
28
  entrypoint: '@tinacms/astro/middleware',
37
29
  order: middlewareOrder,
38
30
  });
39
- // The middleware splices a `<script type="module" src="/_tina/bridge.js">`
40
- // into edit-mode pages. This route serves the bundled bridge.
41
- // Production pages never reference it (the script tag isn't injected).
42
- injectRoute({
43
- pattern: '/_tina/bridge.js',
44
- entrypoint: '@tinacms/astro/bridge-route',
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 multiple times (e.g., layout + page both fetch the
37
- // global). De-dup on id so the bridge doesn't see two open events for
38
- // the same form.
39
- if (list.some((existing) => existing.id === form.id)) return;
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
+ }
@@ -1,33 +1,19 @@
1
1
  /**
2
- * Factory for the dynamic `/tina-island/[name]` endpoint the bridge calls
3
- * to refetch a region with the editor's overlay applied. Each entry in
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 data = await island.fetch(request, url.searchParams);
67
- const container = await AstroContainer.create();
68
- const html = await container.renderToString(island.component, {
69
- props: island.propsFromData(data, url.searchParams),
70
- });
71
- return new Response(wrapIsland(html, island.wrapper, url), {
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
- * Layered defense for an endpoint whose response is shaped by the request
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
- const fetchSite = request.headers.get('sec-fetch-site');
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 { escapeAttr } from './internal/escape';
20
- import { type CollectedForm, formsStore } from './internal/forms-store';
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
- const formDivs = forms
77
- .map(
78
- (form) =>
79
- `<div data-tina-form="${escapeAttr(JSON.stringify(form))}" hidden></div>`
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"/_tina/bridge.js";` +
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;
@@ -1,3 +0,0 @@
1
- import type { APIRoute } from 'astro';
2
- export declare const prerender = false;
3
- export declare const GET: APIRoute;
@@ -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
- };
@@ -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
- });