@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 CHANGED
@@ -14,11 +14,11 @@ pnpm add @tinacms/astro tinacms
14
14
  pnpm add -D @tinacms/cli
15
15
  ```
16
16
 
17
- Requires Astro 5. Also needs an SSR adapter (`@astrojs/node`, `vercel`, `netlify`, or `cloudflare`) and `output: 'server'` in your Astro config.
17
+ Requires Astro 5 and an SSR adapter (`@astrojs/node`, `vercel`, `netlify`, or `cloudflare`) the island-refresh endpoint (`/tina-island/[name]`) is on-demand. `output: 'server'` is the simplest choice; `output: 'static'` also works as long as editable regions are wrapped in [`<TinaIsland>`](#static-site-editing) and the adapter can serve that one on-demand route.
18
18
 
19
19
  ## Usage
20
20
 
21
- Add the integration to `astro.config.mjs` once. It wires the request-scoped middleware and the dynamic route that serves the bridge — everything else is auto-injected only on edit-mode requests:
21
+ Add the integration to `astro.config.mjs` once. It wires the request-scoped middleware and stages the bundled bridge as a static asset at `/admin/bridge.js` — everything else is auto-injected only on edit-mode requests:
22
22
 
23
23
  ```ts
24
24
  // astro.config.mjs
@@ -52,24 +52,37 @@ const post = await requestWithMetadata(
52
52
  </article>
53
53
  ```
54
54
 
55
- That's the whole user surface. No wiring component in your layout, no `forms` prop to maintain, no `Astro.request` to thread. The integration's middleware buffers each HTML response, and on edit-mode requests splices the form payloads and a `<script type="module" src="/_tina/bridge.js">` before `</head>`. Production visitors get **byte-identical HTML to a Tina-free Astro app** — no `data-tina-form` divs, no script tag, no bundle preload.
55
+ That's the whole user surface. No wiring component in your layout, no `forms` prop to maintain, no `Astro.request` to thread. The integration's middleware buffers each HTML response, and on edit-mode requests splices the form payloads and a `<script type="module" src="/admin/bridge.js">` before `</head>`. Production visitors get **byte-identical HTML to a Tina-free Astro app** — no `data-tina-form` divs, no script tag, no bundle preload. (Exception: pages that use [`<TinaIsland>`](#static-site-editing) carry a one-line inline bootstrap so editing also works when the page is statically built.)
56
56
 
57
57
  For cross-origin admin deployments (Codespaces, separate-domain self-hosted), set `PUBLIC_TINA_ADMIN_ORIGIN` in your env (comma-separate to allow multiple). The middleware embeds it inline so the bridge validates inbound `postMessage` events.
58
58
 
59
+ ## Static-site editing
60
+
61
+ `output: 'static'` is supported. The middleware described above only runs on on-demand-rendered routes, so on a prerendered page it never injects anything — instead, **`<TinaIsland>` emits a tiny in-iframe bootstrap script** that loads `/admin/bridge.js` *only* when the page is inside the admin iframe (a no-op for everyone else). On boot the bridge "primes" the page by fetching each island's `/tina-island/[name]` endpoint — which is `prerender = false`, so the adapter still renders it on demand — to pick up the page's form payloads, after which editing works exactly as it does in an SSR project.
62
+
63
+ Requirements for static editing:
64
+
65
+ - Wrap every editable region in `<TinaIsland>` with a registered island (see [GETTING_STARTED.md](./GETTING_STARTED.md) steps 5–7) — that's both how the bridge re-renders regions and how the bootstrap gets onto the page.
66
+ - Pass `primary` on your page's main `<TinaIsland>` (`<TinaIsland name="post" params={{ slug }} primary>`). On a static page the bridge can't tell which island is "the page", so without this the editor may land on the multi-document "Referenced Files" list when the page also has e.g. a global-config form. (On SSR pages the first `requestWithMetadata()` call is treated as primary automatically; pass `{ priority: 'primary' }` as the second argument if you need to override that.) Mark at most one per page.
67
+ - Keep the `tina-island/[name].ts` route (`export const prerender = false`).
68
+
69
+ Trade-off: a page that uses `<TinaIsland>` carries that one-line inline bootstrap in its production HTML, so it's no longer byte-identical to a Tina-free Astro app. Pages without `<TinaIsland>` are unaffected. (On `output: 'server'` the middleware path is unchanged; the bootstrap and the middleware's own injection coexist harmlessly — `bridge.init()` is idempotent.)
70
+
59
71
  ## Subpath exports
60
72
 
61
73
  | Subpath | What it gives you |
62
74
  |---------|-------------------|
63
75
  | `@tinacms/astro` | `requestWithMetadata`, `tinaField`, `QueryResult`, and types |
64
76
  | `@tinacms/astro/TinaMarkdown.astro` | `<TinaMarkdown content components />` — rich-text renderer. Import from this subpath so Astro's check sees a real `.astro` component (the bare-package default resolves through the types condition to a placeholder). |
65
- | `@tinacms/astro/integration` | `tina()` integration — auto-wires middleware + bridge route so `requestWithMetadata()` works without you threading `Astro.request` or writing wiring components |
66
- | `@tinacms/astro/TinaIsland.astro` | `<TinaIsland name wrapper params />` — marker wrapper for an editable region |
77
+ | `@tinacms/astro/integration` | `tina()` integration — auto-wires the middleware and stages the static `/admin/bridge.js` asset so `requestWithMetadata()` works without you threading `Astro.request` or writing wiring components |
78
+ | `@tinacms/astro/TinaIsland.astro` | `<TinaIsland name wrapper params [primary] />` — marker wrapper for an editable region; pass `primary` on the page's main region so the editor opens that form instead of the "Referenced Files" list |
67
79
  | `@tinacms/astro/types` | `TinaRichTextContent`, `CustomComponentsMap`, `TinaRichTextNode`, `MdxElement`, `TextElement` |
68
80
  | `@tinacms/astro/sanitize` | `sanitizeHref` / `sanitizeImageSrc` for CMS-supplied URLs |
69
81
  | `@tinacms/astro/bridge` | `init`, `refreshForms`, and the rest of `@tinacms/bridge` |
70
82
  | `@tinacms/astro/tina-field` | `tinaField()` helper |
71
83
  | `@tinacms/astro/is-edit-mode` | `isEditMode(request)` — server-side admin-iframe detection |
72
84
  | `@tinacms/astro/middleware` | The middleware the integration auto-wires — exported here in case you need to compose it manually |
85
+ | `@tinacms/astro/vite` | `tinaAdminDevRedirect()` — dev-only Vite plugin that redirects `/admin` and `/admin/` to `/admin/index.html` so the admin SPA is reachable from a bare URL during `astro dev` |
73
86
  | `@tinacms/astro/experimental` | `experimental_createIslandRoute()` — opt-in helper built on Astro's unstable `experimental_AstroContainer` |
74
87
 
75
88
  ## Custom MDX components
package/dist/data.d.ts CHANGED
@@ -4,13 +4,16 @@ export interface QueryResult<TData> {
4
4
  variables: Record<string, unknown>;
5
5
  id: string;
6
6
  }
7
- /** Shape every `client.queries.<name>` returns. Inferring from this lets
8
- * `requestWithMetadata()` stay framework-agnostic — it doesn't need to
9
- * know about `PostQuery`, `PageQuery`, etc. */
10
7
  type ClientResult<TData> = {
11
8
  data: TData;
12
9
  query: string;
13
10
  variables: Record<string, unknown>;
14
11
  } | null | undefined;
15
- export declare function requestWithMetadata<TData>(source: ClientResult<TData> | Promise<ClientResult<TData>>): Promise<QueryResult<TData>>;
12
+ export interface RequestOptions {
13
+ /** Mark the page's own document so the admin opens it on load instead
14
+ * of a layout-level global. Mirrors `useTina`'s
15
+ * `experimental___selectFormByFormId`. */
16
+ priority?: 'primary';
17
+ }
18
+ export declare function requestWithMetadata<TData>(source: ClientResult<TData> | Promise<ClientResult<TData>>, options?: RequestOptions): Promise<QueryResult<TData>>;
16
19
  export {};
package/dist/data.js CHANGED
@@ -10,7 +10,11 @@ var formsStore = slot[STORE_KEY] ??= new AsyncLocalStorage();
10
10
  function recordForm(form) {
11
11
  const list = formsStore.getStore();
12
12
  if (!list) return;
13
- if (list.some((existing) => existing.id === form.id)) return;
13
+ const existing = list.find((entry) => entry.id === form.id);
14
+ if (existing) {
15
+ if (form.priority === "primary") existing.priority = "primary";
16
+ return;
17
+ }
14
18
  list.push(form);
15
19
  }
16
20
 
@@ -21,7 +25,7 @@ var slot2 = globalThis;
21
25
  var requestStore = slot2[STORE_KEY2] ??= new AsyncLocalStorage2();
22
26
 
23
27
  // src/data.ts
24
- async function requestWithMetadata(source) {
28
+ async function requestWithMetadata(source, options) {
25
29
  let result = null;
26
30
  try {
27
31
  result = await source ?? null;
@@ -50,7 +54,8 @@ async function requestWithMetadata(source) {
50
54
  id,
51
55
  query,
52
56
  variables,
53
- data: enriched.data
57
+ data: enriched.data,
58
+ priority: options?.priority
54
59
  });
55
60
  return enriched;
56
61
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,12 +1,32 @@
1
1
  // src/island-route.ts
2
+ import { PREVIEW_CONTENT_TYPE, PRIME_HEADER } from "@tinacms/bridge/preview";
2
3
  import { experimental_AstroContainer as AstroContainer } from "astro/container";
3
- import { PREVIEW_CONTENT_TYPE } from "@tinacms/bridge/preview";
4
4
 
5
5
  // src/internal/escape.ts
6
6
  function escapeAttr(s) {
7
7
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
8
8
  }
9
9
 
10
+ // src/internal/forms-store.ts
11
+ import { AsyncLocalStorage } from "node:async_hooks";
12
+ var STORE_KEY = Symbol.for("@tinacms/astro/forms-store");
13
+ var slot = globalThis;
14
+ var formsStore = slot[STORE_KEY] ??= new AsyncLocalStorage();
15
+ function sortByPriority(forms) {
16
+ return [...forms].sort(
17
+ (a, b) => (a.priority === "primary" ? 0 : 1) - (b.priority === "primary" ? 0 : 1)
18
+ );
19
+ }
20
+ function renderFormPayloadDiv(form, primary) {
21
+ return `<div data-tina-form="${escapeAttr(JSON.stringify(form))}"${primary ? " data-tina-primary" : ""} hidden></div>`;
22
+ }
23
+
24
+ // src/internal/request-context.ts
25
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
26
+ var STORE_KEY2 = Symbol.for("@tinacms/astro/request-context");
27
+ var slot2 = globalThis;
28
+ var requestStore = slot2[STORE_KEY2] ??= new AsyncLocalStorage2();
29
+
10
30
  // src/island-route.ts
11
31
  function experimental_createIslandRoute(islands) {
12
32
  return async ({ params, request, url }) => {
@@ -16,13 +36,21 @@ function experimental_createIslandRoute(islands) {
16
36
  if (!island) {
17
37
  return new Response(`Unknown island "${params.name}"`, { status: 404 });
18
38
  }
39
+ const priming = request.headers.get(PRIME_HEADER) !== null;
19
40
  try {
20
- const data = await island.fetch(request, url.searchParams);
21
- const container = await AstroContainer.create();
22
- const html = await container.renderToString(island.component, {
23
- props: island.propsFromData(data, url.searchParams)
24
- });
25
- return new Response(wrapIsland(html, island.wrapper, url), {
41
+ const forms = [];
42
+ const html = await requestStore.run(
43
+ request,
44
+ () => formsStore.run(forms, async () => {
45
+ const data = await island.fetch(request, url.searchParams);
46
+ const container = await AstroContainer.create();
47
+ return container.renderToString(island.component, {
48
+ props: island.propsFromData(data, url.searchParams)
49
+ });
50
+ })
51
+ );
52
+ const body = (priming ? renderFormPayloads(forms) : "") + wrapIsland(html, island.wrapper, url);
53
+ return new Response(body, {
26
54
  headers: {
27
55
  "Content-Type": "text/html; charset=utf-8",
28
56
  "Cache-Control": "no-store"
@@ -41,12 +69,14 @@ function rejectIfUnsafe(request) {
41
69
  if (!contentType.includes(PREVIEW_CONTENT_TYPE)) {
42
70
  return new Response("Not Found", { status: 404 });
43
71
  }
44
- const fetchSite = request.headers.get("sec-fetch-site");
45
- if (fetchSite === "cross-site" || fetchSite === "cross-origin") {
72
+ if (request.headers.get("sec-fetch-site") === "cross-site") {
46
73
  return new Response("Forbidden", { status: 403 });
47
74
  }
48
75
  return null;
49
76
  }
77
+ function renderFormPayloads(forms) {
78
+ return sortByPriority(forms).map((form) => renderFormPayloadDiv(form, form.priority === "primary")).join("");
79
+ }
50
80
  function wrapIsland(html, wrapper, url) {
51
81
  const cls = wrapper.className ? ` class="${escapeAttr(wrapper.className)}"` : "";
52
82
  const marker = escapeAttr(`${url.pathname}${url.search}`);
package/dist/index.js CHANGED
@@ -24,7 +24,11 @@ var formsStore = slot[STORE_KEY] ??= new AsyncLocalStorage();
24
24
  function recordForm(form) {
25
25
  const list = formsStore.getStore();
26
26
  if (!list) return;
27
- if (list.some((existing) => existing.id === form.id)) return;
27
+ const existing = list.find((entry) => entry.id === form.id);
28
+ if (existing) {
29
+ if (form.priority === "primary") existing.priority = "primary";
30
+ return;
31
+ }
28
32
  list.push(form);
29
33
  }
30
34
 
@@ -35,7 +39,7 @@ var slot2 = globalThis;
35
39
  var requestStore = slot2[STORE_KEY2] ??= new AsyncLocalStorage2();
36
40
 
37
41
  // src/data.ts
38
- async function requestWithMetadata(source) {
42
+ async function requestWithMetadata(source, options) {
39
43
  let result = null;
40
44
  try {
41
45
  result = await source ?? null;
@@ -64,7 +68,8 @@ async function requestWithMetadata(source) {
64
68
  id,
65
69
  query,
66
70
  variables,
67
- data: enriched.data
71
+ data: enriched.data,
72
+ priority: options?.priority
68
73
  });
69
74
  return enriched;
70
75
  }
@@ -1,25 +1,5 @@
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
1
  import type { AstroIntegration } from 'astro';
19
2
  export interface TinaIntegrationOptions {
20
- /** Override the middleware ordering relative to other integrations.
21
- * Defaults to `'pre'` so `Astro.locals.tinaEdit` is populated before
22
- * user middleware sees the request. */
23
3
  middlewareOrder?: 'pre' | 'post';
24
4
  }
25
5
  export default function tina(options?: TinaIntegrationOptions): AstroIntegration;
@@ -1,22 +1,69 @@
1
1
  // src/integration.ts
2
+ import { copyFileSync, mkdirSync, readFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ var BRIDGE_ROUTE = "/admin/bridge.js";
2
7
  function tina(options = {}) {
3
8
  const { middlewareOrder = "pre" } = options;
9
+ let clientDir;
4
10
  return {
5
11
  name: "@tinacms/astro",
6
12
  hooks: {
7
- "astro:config:setup": ({ addMiddleware, injectRoute }) => {
13
+ "astro:config:setup": ({ addMiddleware, updateConfig }) => {
8
14
  addMiddleware({
9
15
  entrypoint: "@tinacms/astro/middleware",
10
16
  order: middlewareOrder
11
17
  });
12
- injectRoute({
13
- pattern: "/_tina/bridge.js",
14
- entrypoint: "@tinacms/astro/bridge-route"
15
- });
18
+ updateConfig({ vite: { plugins: [bridgeDevPlugin()] } });
19
+ },
20
+ "astro:config:done": ({ config }) => {
21
+ clientDir = config.build.client;
22
+ },
23
+ "astro:build:done": ({ logger }) => {
24
+ if (!clientDir) return;
25
+ emitBridgeAsset(fileURLToPath(new URL("admin/", clientDir)), logger);
16
26
  }
17
27
  }
18
28
  };
19
29
  }
30
+ function resolveBridge() {
31
+ return createRequire(import.meta.url).resolve("@tinacms/bridge");
32
+ }
33
+ function bridgeDevPlugin() {
34
+ return {
35
+ name: "@tinacms/astro:bridge-dev",
36
+ apply: "serve",
37
+ configureServer(server) {
38
+ server.middlewares.use((req, res, next) => {
39
+ const path = (req.url || "").split("?")[0];
40
+ if (path !== BRIDGE_ROUTE) {
41
+ next();
42
+ return;
43
+ }
44
+ try {
45
+ const body = readFileSync(resolveBridge());
46
+ res.setHeader("Content-Type", "text/javascript");
47
+ res.setHeader("Cache-Control", "no-cache");
48
+ res.end(body);
49
+ } catch (error) {
50
+ res.statusCode = 500;
51
+ res.end(`/* @tinacms/astro: bridge unavailable: ${error} */`);
52
+ }
53
+ });
54
+ }
55
+ };
56
+ }
57
+ function emitBridgeAsset(adminDir, logger) {
58
+ try {
59
+ mkdirSync(adminDir, { recursive: true });
60
+ copyFileSync(resolveBridge(), join(adminDir, "bridge.js"));
61
+ } catch (error) {
62
+ logger.warn(
63
+ `could not emit admin/bridge.js \u2014 visual editing will not load: ${error}`
64
+ );
65
+ }
66
+ }
20
67
  export {
21
68
  tina as default
22
69
  };
@@ -0,0 +1,6 @@
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 declare function adminOrigins(): string[] | null;
@@ -1,23 +1,12 @@
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';
16
2
  export interface CollectedForm {
17
3
  id: string;
18
4
  query: string;
19
5
  variables: object;
20
6
  data: object;
7
+ priority?: 'primary';
21
8
  }
22
9
  export declare const formsStore: AsyncLocalStorage<CollectedForm[]>;
23
10
  export declare function recordForm(form: CollectedForm): void;
11
+ export declare function sortByPriority(forms: CollectedForm[]): CollectedForm[];
12
+ export declare function renderFormPayloadDiv(form: CollectedForm, primary: boolean): string;
@@ -1,28 +1,3 @@
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
- * ```
25
- */
26
1
  import type { APIRoute } from 'astro';
27
2
  import type { AstroComponentFactory } from 'astro/runtime/server/index.js';
28
3
  export interface IslandWrapper {
@@ -1,12 +1,32 @@
1
1
  // src/island-route.ts
2
+ import { PREVIEW_CONTENT_TYPE, PRIME_HEADER } from "@tinacms/bridge/preview";
2
3
  import { experimental_AstroContainer as AstroContainer } from "astro/container";
3
- import { PREVIEW_CONTENT_TYPE } from "@tinacms/bridge/preview";
4
4
 
5
5
  // src/internal/escape.ts
6
6
  function escapeAttr(s) {
7
7
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
8
8
  }
9
9
 
10
+ // src/internal/forms-store.ts
11
+ import { AsyncLocalStorage } from "node:async_hooks";
12
+ var STORE_KEY = Symbol.for("@tinacms/astro/forms-store");
13
+ var slot = globalThis;
14
+ var formsStore = slot[STORE_KEY] ??= new AsyncLocalStorage();
15
+ function sortByPriority(forms) {
16
+ return [...forms].sort(
17
+ (a, b) => (a.priority === "primary" ? 0 : 1) - (b.priority === "primary" ? 0 : 1)
18
+ );
19
+ }
20
+ function renderFormPayloadDiv(form, primary) {
21
+ return `<div data-tina-form="${escapeAttr(JSON.stringify(form))}"${primary ? " data-tina-primary" : ""} hidden></div>`;
22
+ }
23
+
24
+ // src/internal/request-context.ts
25
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
26
+ var STORE_KEY2 = Symbol.for("@tinacms/astro/request-context");
27
+ var slot2 = globalThis;
28
+ var requestStore = slot2[STORE_KEY2] ??= new AsyncLocalStorage2();
29
+
10
30
  // src/island-route.ts
11
31
  function experimental_createIslandRoute(islands) {
12
32
  return async ({ params, request, url }) => {
@@ -16,13 +36,21 @@ function experimental_createIslandRoute(islands) {
16
36
  if (!island) {
17
37
  return new Response(`Unknown island "${params.name}"`, { status: 404 });
18
38
  }
39
+ const priming = request.headers.get(PRIME_HEADER) !== null;
19
40
  try {
20
- const data = await island.fetch(request, url.searchParams);
21
- const container = await AstroContainer.create();
22
- const html = await container.renderToString(island.component, {
23
- props: island.propsFromData(data, url.searchParams)
24
- });
25
- return new Response(wrapIsland(html, island.wrapper, url), {
41
+ const forms = [];
42
+ const html = await requestStore.run(
43
+ request,
44
+ () => formsStore.run(forms, async () => {
45
+ const data = await island.fetch(request, url.searchParams);
46
+ const container = await AstroContainer.create();
47
+ return container.renderToString(island.component, {
48
+ props: island.propsFromData(data, url.searchParams)
49
+ });
50
+ })
51
+ );
52
+ const body = (priming ? renderFormPayloads(forms) : "") + wrapIsland(html, island.wrapper, url);
53
+ return new Response(body, {
26
54
  headers: {
27
55
  "Content-Type": "text/html; charset=utf-8",
28
56
  "Cache-Control": "no-store"
@@ -41,12 +69,14 @@ function rejectIfUnsafe(request) {
41
69
  if (!contentType.includes(PREVIEW_CONTENT_TYPE)) {
42
70
  return new Response("Not Found", { status: 404 });
43
71
  }
44
- const fetchSite = request.headers.get("sec-fetch-site");
45
- if (fetchSite === "cross-site" || fetchSite === "cross-origin") {
72
+ if (request.headers.get("sec-fetch-site") === "cross-site") {
46
73
  return new Response("Forbidden", { status: 403 });
47
74
  }
48
75
  return null;
49
76
  }
77
+ function renderFormPayloads(forms) {
78
+ return sortByPriority(forms).map((form) => renderFormPayloadDiv(form, form.priority === "primary")).join("");
79
+ }
50
80
  function wrapIsland(html, wrapper, url) {
51
81
  const cls = wrapper.className ? ` class="${escapeAttr(wrapper.className)}"` : "";
52
82
  const marker = escapeAttr(`${url.pathname}${url.search}`);
@@ -1,20 +1,3 @@
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
2
  export declare const onRequest: MiddlewareHandler;
20
3
  export default onRequest;
@@ -1,13 +1,32 @@
1
+ // src/internal/admin-origin.ts
2
+ function adminOrigins() {
3
+ const env = import.meta.env;
4
+ const raw = env?.PUBLIC_TINA_ADMIN_ORIGIN;
5
+ if (!raw) return null;
6
+ const origins = raw.split(",").map((s) => s.trim()).filter(Boolean);
7
+ return origins.length > 0 ? origins : null;
8
+ }
9
+
10
+ // src/internal/forms-store.ts
11
+ import { AsyncLocalStorage } from "node:async_hooks";
12
+
1
13
  // src/internal/escape.ts
2
14
  function escapeAttr(s) {
3
15
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
4
16
  }
5
17
 
6
18
  // src/internal/forms-store.ts
7
- import { AsyncLocalStorage } from "node:async_hooks";
8
19
  var STORE_KEY = Symbol.for("@tinacms/astro/forms-store");
9
20
  var slot = globalThis;
10
21
  var formsStore = slot[STORE_KEY] ??= new AsyncLocalStorage();
22
+ function sortByPriority(forms) {
23
+ return [...forms].sort(
24
+ (a, b) => (a.priority === "primary" ? 0 : 1) - (b.priority === "primary" ? 0 : 1)
25
+ );
26
+ }
27
+ function renderFormPayloadDiv(form, primary) {
28
+ return `<div data-tina-form="${escapeAttr(JSON.stringify(form))}"${primary ? " data-tina-primary" : ""} hidden></div>`;
29
+ }
11
30
 
12
31
  // src/internal/request-context.ts
13
32
  import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
@@ -50,6 +69,10 @@ function readCookie(request, name) {
50
69
  // src/middleware.ts
51
70
  var HEAD_CLOSE = "</head>";
52
71
  var onRequest = (context, next) => {
72
+ if (context.isPrerendered) {
73
+ context.locals.tinaEdit = false;
74
+ return next();
75
+ }
53
76
  const editing = isEditMode(context.request);
54
77
  context.locals.tinaEdit = editing;
55
78
  const forms = [];
@@ -86,22 +109,13 @@ function editModeInit(response) {
86
109
  return { status: response.status, statusText: response.statusText, headers };
87
110
  }
88
111
  function renderInjection(forms) {
89
- const formDivs = forms.map(
90
- (form) => `<div data-tina-form="${escapeAttr(JSON.stringify(form))}" hidden></div>`
91
- ).join("");
112
+ const formDivs = sortByPriority(forms).map((form, i) => renderFormPayloadDiv(form, i === 0)).join("");
92
113
  return formDivs + bridgeScript();
93
114
  }
94
115
  function bridgeScript() {
95
116
  const origins = adminOrigins();
96
117
  const initArg = origins ? `{adminOrigin:${JSON.stringify(origins)}}` : "";
97
- return `<script type="module">import{init,refreshForms}from"/_tina/bridge.js";init(${initArg});document.addEventListener("astro:page-load",refreshForms);</script>`;
98
- }
99
- function adminOrigins() {
100
- const env = import.meta.env;
101
- const raw = env?.PUBLIC_TINA_ADMIN_ORIGIN;
102
- if (!raw) return null;
103
- const origins = raw.split(",").map((s) => s.trim()).filter(Boolean);
104
- return origins.length > 0 ? origins : null;
118
+ return `<script type="module">import{init,refreshForms}from"/admin/bridge.js";init(${initArg});document.addEventListener("astro:page-load",refreshForms);</script>`;
105
119
  }
106
120
  export {
107
121
  middleware_default as default,
package/dist/vite.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import type { Plugin } from 'vite';
2
+ /**
3
+ * Dev-only Vite plugin that redirects `/admin` (and `/admin/`) to
4
+ * `/admin/index.html`.
5
+ *
6
+ * In `astro dev`, the admin SPA is served straight from `public/admin/` and
7
+ * Vite does not resolve a directory index for it, so a bare `/admin` request
8
+ * 404s. This plugin makes `/admin` land on the SPA the same way it does in a
9
+ * production build. It only applies to the dev server (`apply: 'serve'`); a
10
+ * built site serves `public/admin/index.html` itself.
11
+ *
12
+ * @example
13
+ * // astro.config.mjs
14
+ * import { tinaAdminDevRedirect } from '@tinacms/astro/vite';
15
+ *
16
+ * export default defineConfig({
17
+ * vite: { plugins: [tinaAdminDevRedirect()] },
18
+ * });
19
+ */
20
+ export declare function tinaAdminDevRedirect(): Plugin;
21
+ export default tinaAdminDevRedirect;
package/dist/vite.js ADDED
@@ -0,0 +1,24 @@
1
+ // src/vite.ts
2
+ function tinaAdminDevRedirect() {
3
+ return {
4
+ name: "tina-admin-dev-redirect",
5
+ apply: "serve",
6
+ configureServer(server) {
7
+ server.middlewares.use((req, res, next) => {
8
+ const path = (req.url || "").split("?")[0];
9
+ if (path === "/admin" || path === "/admin/") {
10
+ res.statusCode = 302;
11
+ res.setHeader("Location", "/admin/index.html");
12
+ res.end();
13
+ return;
14
+ }
15
+ next();
16
+ });
17
+ }
18
+ };
19
+ }
20
+ var vite_default = tinaAdminDevRedirect;
21
+ export {
22
+ vite_default as default,
23
+ tinaAdminDevRedirect
24
+ };