@tinacms/astro 0.0.1 → 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.
Files changed (75) hide show
  1. package/README.md +50 -20
  2. package/dist/bridge.d.ts +7 -1
  3. package/dist/bridge.js +1 -0
  4. package/dist/data.d.ts +19 -0
  5. package/dist/data.js +64 -0
  6. package/dist/data.test-d.d.ts +1 -0
  7. package/dist/experimental.d.ts +10 -0
  8. package/dist/experimental.js +87 -0
  9. package/dist/index.d.ts +36 -1
  10. package/dist/index.js +92 -2
  11. package/dist/integration.d.ts +5 -0
  12. package/dist/integration.js +69 -0
  13. package/dist/internal/admin-origin.d.ts +6 -0
  14. package/dist/internal/escape.d.ts +8 -0
  15. package/dist/internal/forms-store.d.ts +12 -0
  16. package/dist/internal/request-context.d.ts +16 -0
  17. package/dist/is-edit-mode.d.ts +32 -0
  18. package/dist/is-edit-mode.js +37 -0
  19. package/dist/island-route.d.ts +18 -0
  20. package/dist/island-route.js +87 -0
  21. package/dist/middleware.d.ts +3 -0
  22. package/dist/middleware.js +123 -0
  23. package/dist/sanitize.d.ts +12 -1
  24. package/dist/sanitize.js +6 -10
  25. package/dist/tina-field.d.ts +1 -1
  26. package/dist/tina-field.js +1 -0
  27. package/dist/types.d.ts +92 -1
  28. package/dist/types.js +0 -1
  29. package/dist/vite.d.ts +21 -0
  30. package/dist/vite.js +24 -0
  31. package/package.json +88 -17
  32. package/src/CodeBlockNode.astro +28 -0
  33. package/src/Container.astro +56 -0
  34. package/src/ImageNode.astro +17 -0
  35. package/src/LinkNode.astro +22 -0
  36. package/src/MdxNode.astro +24 -0
  37. package/src/Node.astro +11 -4
  38. package/src/TinaIsland.astro +44 -0
  39. package/src/TinaMarkdown.astro +8 -0
  40. package/src/__tests__/IslandStub.astro +8 -0
  41. package/src/__tests__/TinaIsland.test.ts +60 -0
  42. package/src/__tests__/TinaMarkdown.test.ts +112 -0
  43. package/src/__tests__/__snapshots__/TinaMarkdown.test.ts.snap +7 -0
  44. package/src/__tests__/fixtures/FancyHeading.astro +3 -0
  45. package/src/__tests__/fixtures/MyFeature.astro +4 -0
  46. package/src/__tests__/fixtures/basic-kitchen-sink.json +60 -0
  47. package/src/__tests__/fixtures/code-block.json +34 -0
  48. package/src/__tests__/fixtures/leaf-marks.json +199 -0
  49. package/src/__tests__/fixtures/mdx-jsx-flow.json +40 -0
  50. package/src/__tests__/fixtures/mdx-jsx-text.json +53 -0
  51. package/src/__tests__/forms-store.test.ts +70 -0
  52. package/src/__tests__/integration.test.ts +124 -0
  53. package/src/__tests__/island-route.test.ts +119 -0
  54. package/src/__tests__/middleware.test.ts +102 -0
  55. package/src/__tests__/sanitize.test.ts +75 -0
  56. package/src/__tests__/vite.test.ts +67 -0
  57. package/src/bridge.ts +7 -0
  58. package/src/data.test-d.ts +53 -0
  59. package/src/data.ts +73 -0
  60. package/src/experimental.ts +14 -0
  61. package/src/index.ts +54 -0
  62. package/src/integration.ts +94 -0
  63. package/src/internal/admin-origin.ts +19 -0
  64. package/src/internal/escape.ts +15 -0
  65. package/src/internal/forms-store.ts +50 -0
  66. package/src/internal/request-context.ts +23 -0
  67. package/src/is-edit-mode.ts +68 -0
  68. package/src/island-route.ts +109 -0
  69. package/src/middleware.ts +89 -0
  70. package/src/sanitize.ts +64 -0
  71. package/src/tina-field.ts +1 -0
  72. package/src/types.ts +97 -0
  73. package/src/vite.ts +40 -0
  74. package/dist/preview.d.ts +0 -1
  75. package/dist/preview.js +0 -1
@@ -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 ADDED
@@ -0,0 +1,73 @@
1
+ import { addMetadata, hashFromQuery } from '@tinacms/bridge/metadata';
2
+ import { readOverlay } from '@tinacms/bridge/preview';
3
+ import { recordForm } from './internal/forms-store';
4
+ import { requestStore } from './internal/request-context';
5
+
6
+ export interface QueryResult<TData> {
7
+ data: TData;
8
+ query: string;
9
+ variables: Record<string, unknown>;
10
+ id: string;
11
+ }
12
+
13
+ type ClientResult<TData> =
14
+ | {
15
+ data: TData;
16
+ query: string;
17
+ variables: Record<string, unknown>;
18
+ }
19
+ | null
20
+ | undefined;
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
+
29
+ export async function requestWithMetadata<TData>(
30
+ source: ClientResult<TData> | Promise<ClientResult<TData>>,
31
+ options?: RequestOptions
32
+ ): Promise<QueryResult<TData>> {
33
+ let result: ClientResult<TData> = null;
34
+ try {
35
+ result = (await source) ?? null;
36
+ } catch (error) {
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.
39
+ console.warn('[@tinacms/astro] client query failed', error);
40
+ }
41
+
42
+ const query = result?.query ?? '';
43
+ const variables = result?.variables ?? {};
44
+ const id = hashFromQuery(JSON.stringify({ query, variables }));
45
+ const data = (result?.data ?? {}) as TData;
46
+
47
+ const request = requestStore.getStore();
48
+ let resolvedData: TData = data;
49
+ if (request) {
50
+ const overlay = await readOverlay<TData>(request, id);
51
+ if (overlay !== undefined) {
52
+ resolvedData = overlay;
53
+ }
54
+ }
55
+
56
+ const enriched = {
57
+ data: addMetadata(id, resolvedData) as TData,
58
+ query,
59
+ variables,
60
+ id,
61
+ };
62
+
63
+ // No-op outside a request scope (static builds).
64
+ recordForm({
65
+ id,
66
+ query,
67
+ variables,
68
+ data: enriched.data as unknown as object,
69
+ priority: options?.priority,
70
+ });
71
+
72
+ return enriched;
73
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Experimental surface area. Anything here may change in a minor or
3
+ * patch release — opt in only when the underlying tradeoff is worth
4
+ * it for your project.
5
+ *
6
+ * `experimental_createIslandRoute` builds on Astro's
7
+ * `experimental_AstroContainer`, which Astro itself flags as unstable;
8
+ * this re-export inherits the same caveat.
9
+ */
10
+ export {
11
+ experimental_createIslandRoute,
12
+ type IslandConfig,
13
+ type IslandRegistry,
14
+ } from './island-route';
package/src/index.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Runtime entry for the package's `.` subpath. Astro consumers resolve
3
+ * through the `astro` export condition straight to `src/TinaMarkdown.astro`
4
+ * — they get the real component as the default export plus the named
5
+ * helpers re-exported from the .astro frontmatter.
6
+ *
7
+ * This file is the fallback for any tool that *doesn't* understand the
8
+ * `astro` condition (TypeScript types, plain Node ESM resolution). It
9
+ * exposes the named runtime helpers and a placeholder default that
10
+ * throws with a clear redirect if someone reaches it.
11
+ */
12
+ import type { AstroComponentFactory } from 'astro/runtime/server/index.js';
13
+ import type { CustomComponentsMap, TinaRichTextContent } from './types';
14
+
15
+ export { requestWithMetadata, type QueryResult } from './data';
16
+ export { tinaField } from './tina-field';
17
+ export type {
18
+ AstroComponent,
19
+ CustomComponentsMap,
20
+ MdxElement,
21
+ TextElement,
22
+ TinaRichTextContent,
23
+ TinaRichTextNode,
24
+ TinaRichTextRoot,
25
+ } from './types';
26
+
27
+ /**
28
+ * Typed shape of `<TinaMarkdown content={...} components={...} />` for tools
29
+ * that resolve through the `types` / `default` export condition rather than
30
+ * the `astro` condition. The actual component is `src/TinaMarkdown.astro`;
31
+ * this placeholder throws if invoked because the only legitimate caller is
32
+ * Astro's component pipeline, which reaches the .astro file directly.
33
+ *
34
+ * The intersection of `AstroComponentFactory` (Astro's component-validity
35
+ * check) and a typed prop signature (TinaMarkdown's actual API) gives the
36
+ * Astro language server enough shape to both recognise this as a renderable
37
+ * component AND offer prop completions / type errors at the call site.
38
+ */
39
+ type TinaMarkdownComponent = AstroComponentFactory & {
40
+ (props: {
41
+ content: TinaRichTextContent;
42
+ components?: CustomComponentsMap;
43
+ }): unknown;
44
+ };
45
+
46
+ const TinaMarkdownPlaceholder = (() => {
47
+ throw new Error(
48
+ "[@tinacms/astro] TinaMarkdown must be loaded through Astro's pipeline. " +
49
+ 'Add `tina()` from `@tinacms/astro/integration` to your astro.config integrations, ' +
50
+ 'or import directly from `@tinacms/astro/TinaMarkdown.astro`.'
51
+ );
52
+ }) as unknown as TinaMarkdownComponent;
53
+
54
+ export default TinaMarkdownPlaceholder;
@@ -0,0 +1,94 @@
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';
7
+
8
+ export interface TinaIntegrationOptions {
9
+ middlewareOrder?: 'pre' | 'post';
10
+ }
11
+
12
+ /** Where the injected bridge bootstrap imports the bridge bundle from. */
13
+ const BRIDGE_ROUTE = '/admin/bridge.js';
14
+
15
+ export default function tina(
16
+ options: TinaIntegrationOptions = {}
17
+ ): AstroIntegration {
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
+
23
+ return {
24
+ name: '@tinacms/astro',
25
+ hooks: {
26
+ 'astro:config:setup': ({ addMiddleware, updateConfig }) => {
27
+ addMiddleware({
28
+ entrypoint: '@tinacms/astro/middleware',
29
+ order: middlewareOrder,
30
+ });
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;
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
+ });
81
+ },
82
+ };
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
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * HTML attribute escape for double-quoted attributes. `&` is escaped first
3
+ * so the subsequent replacements don't double-encode existing entities.
4
+ * Adequate for the shapes we emit server-side — `data-tina-form` payloads
5
+ * and `data-tina-island` marker paths — neither of which is parsed as
6
+ * HTML downstream.
7
+ */
8
+ export function escapeAttr(s: string): string {
9
+ return s
10
+ .replace(/&/g, '&amp;')
11
+ .replace(/</g, '&lt;')
12
+ .replace(/>/g, '&gt;')
13
+ .replace(/"/g, '&quot;')
14
+ .replace(/'/g, '&#39;');
15
+ }
@@ -0,0 +1,50 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { escapeAttr } from './escape';
3
+
4
+ export interface CollectedForm {
5
+ id: string;
6
+ query: string;
7
+ variables: object;
8
+ data: object;
9
+ priority?: 'primary';
10
+ }
11
+
12
+ // Symbol slot on globalThis so bundle copies (esbuild inlines this module
13
+ // into each entry that imports it) share one ALS instance.
14
+ const STORE_KEY = Symbol.for('@tinacms/astro/forms-store');
15
+
16
+ type Slot = { [STORE_KEY]?: AsyncLocalStorage<CollectedForm[]> };
17
+ const slot = globalThis as unknown as Slot;
18
+
19
+ export const formsStore: AsyncLocalStorage<CollectedForm[]> = (slot[
20
+ STORE_KEY
21
+ ] ??= new AsyncLocalStorage<CollectedForm[]>());
22
+
23
+ export function recordForm(form: CollectedForm): void {
24
+ const list = formsStore.getStore();
25
+ if (!list) 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
+ }
33
+ list.push(form);
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
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Request-scoped storage so `tina()` can read the current request without
3
+ * the caller threading `Astro.request` through every loader. The
4
+ * middleware injected by the `tina()` integration runs every request
5
+ * through `requestStore.run(...)`.
6
+ *
7
+ * Falls through to `undefined` outside a request scope (static builds,
8
+ * integration not installed) — `tina()` treats that as "no overlay,
9
+ * no edit mode," so static contexts still produce correct output.
10
+ *
11
+ * Stashed on `globalThis` via `Symbol.for(...)` so all bundle copies of
12
+ * this module share one ALS instance — esbuild inlines it into every
13
+ * entry that imports it, and per-entry copies wouldn't share state.
14
+ */
15
+ import { AsyncLocalStorage } from 'node:async_hooks';
16
+
17
+ const STORE_KEY = Symbol.for('@tinacms/astro/request-context');
18
+
19
+ type Slot = { [STORE_KEY]?: AsyncLocalStorage<Request> };
20
+ const slot = globalThis as unknown as Slot;
21
+
22
+ export const requestStore: AsyncLocalStorage<Request> = (slot[STORE_KEY] ??=
23
+ new AsyncLocalStorage<Request>());
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Server-side check: is this request being rendered inside the TinaCMS
3
+ * admin iframe?
4
+ *
5
+ * Resolves true when any of the following hold:
6
+ * 1. `?tina-edit=1` — explicit signal added by the admin's router for
7
+ * deep-link previews.
8
+ * 2. `Sec-Fetch-Dest: iframe` AND a same-origin Referer under
9
+ * `/admin/` — covers the first request after the admin sets
10
+ * `iframe.src` to a preview URL.
11
+ * 3. `Sec-Fetch-Dest: iframe` AND a `__tina_edit=1` cookie — covers
12
+ * in-iframe link clicks, where the Referer is the previous preview
13
+ * page rather than `/admin/`. The middleware sets the cookie on
14
+ * every edit-mode response so the session sticks for the iframe's
15
+ * lifetime.
16
+ *
17
+ * Direct browser visits set `Sec-Fetch-Dest: document` for top-level
18
+ * navigations, so a stale cookie left behind in someone's browser can
19
+ * never trip edit mode outside an iframe — production HTML is byte-
20
+ * identical to a Tina-free Astro app for end users.
21
+ */
22
+ export const EDIT_COOKIE = '__tina_edit';
23
+
24
+ /**
25
+ * Set-Cookie header value the middleware writes on every edit-mode
26
+ * response. Refreshing on each response keeps long editing sessions
27
+ * sticky and short Max-Age limits the blast radius if a cookie lingers.
28
+ * The `Sec-Fetch-Dest: iframe` gate in `isEditMode` blocks the cookie
29
+ * from triggering edit mode on top-level visits.
30
+ */
31
+ export const EDIT_COOKIE_HEADER = `${EDIT_COOKIE}=1; Path=/; SameSite=Strict; Max-Age=3600`;
32
+
33
+ export function isEditMode(request: Request): boolean {
34
+ const url = new URL(request.url);
35
+ if (url.searchParams.get('tina-edit') === '1') return true;
36
+
37
+ const dest = request.headers.get('Sec-Fetch-Dest');
38
+ if (dest !== 'iframe') return false;
39
+
40
+ const referer = request.headers.get('Referer');
41
+ if (referer) {
42
+ try {
43
+ const refererUrl = new URL(referer);
44
+ if (
45
+ refererUrl.origin === url.origin &&
46
+ refererUrl.pathname.startsWith('/admin/')
47
+ ) {
48
+ return true;
49
+ }
50
+ } catch {
51
+ // Malformed Referer — fall through to cookie check.
52
+ }
53
+ }
54
+
55
+ return readCookie(request, EDIT_COOKIE) === '1';
56
+ }
57
+
58
+ export function readCookie(request: Request, name: string): string | null {
59
+ const header = request.headers.get('Cookie');
60
+ if (!header) return null;
61
+ for (const pair of header.split(';')) {
62
+ const eq = pair.indexOf('=');
63
+ if (eq === -1) continue;
64
+ const key = pair.slice(0, eq).trim();
65
+ if (key === name) return pair.slice(eq + 1).trim();
66
+ }
67
+ return null;
68
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * @experimental Built on Astro's `experimental_AstroContainer`. Both APIs
3
+ * may break in any Astro minor/patch.
4
+ */
5
+ import { PREVIEW_CONTENT_TYPE, PRIME_HEADER } from '@tinacms/bridge/preview';
6
+ import type { APIRoute } from 'astro';
7
+ import { experimental_AstroContainer as AstroContainer } from 'astro/container';
8
+ import type { AstroComponentFactory } from 'astro/runtime/server/index.js';
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';
17
+
18
+ export interface IslandWrapper {
19
+ tag: string;
20
+ className?: string;
21
+ }
22
+
23
+ export interface IslandConfig {
24
+ /** Resolve the data the component needs. May ignore the search params. */
25
+ fetch: (request: Request, params: URLSearchParams) => Promise<unknown>;
26
+ /** Astro component to render with the fetched data. */
27
+ component: AstroComponentFactory;
28
+ /** Outer element the bridge swaps into — must match the page-side wrapper. */
29
+ wrapper: IslandWrapper;
30
+ /** Map fetched data + URL params to the component's props. */
31
+ propsFromData: (
32
+ data: unknown,
33
+ params: URLSearchParams
34
+ ) => Record<string, unknown>;
35
+ }
36
+
37
+ export type IslandRegistry = Record<string, IslandConfig>;
38
+
39
+ export function experimental_createIslandRoute(
40
+ islands: IslandRegistry
41
+ ): APIRoute {
42
+ return async ({ params, request, url }) => {
43
+ const rejection = rejectIfUnsafe(request);
44
+ if (rejection) return rejection;
45
+
46
+ const island = islands[params.name ?? ''];
47
+ if (!island) {
48
+ return new Response(`Unknown island "${params.name}"`, { status: 404 });
49
+ }
50
+
51
+ const priming = request.headers.get(PRIME_HEADER) !== null;
52
+
53
+ try {
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, {
68
+ headers: {
69
+ 'Content-Type': 'text/html; charset=utf-8',
70
+ 'Cache-Control': 'no-store',
71
+ },
72
+ });
73
+ } catch {
74
+ return new Response('Island render failed', { status: 500 });
75
+ }
76
+ };
77
+ }
78
+
79
+ // Bridge issues a same-origin POST with the Tina-preview content-type;
80
+ // production traffic can't match all three signals.
81
+ function rejectIfUnsafe(request: Request): Response | null {
82
+ if (request.method !== 'POST') {
83
+ return new Response('Method Not Allowed', { status: 405 });
84
+ }
85
+ const contentType = request.headers.get('content-type') ?? '';
86
+ if (!contentType.includes(PREVIEW_CONTENT_TYPE)) {
87
+ return new Response('Not Found', { status: 404 });
88
+ }
89
+ if (request.headers.get('sec-fetch-site') === 'cross-site') {
90
+ return new Response('Forbidden', { status: 403 });
91
+ }
92
+ return null;
93
+ }
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
+
103
+ function wrapIsland(html: string, wrapper: IslandWrapper, url: URL): string {
104
+ const cls = wrapper.className
105
+ ? ` class="${escapeAttr(wrapper.className)}"`
106
+ : '';
107
+ const marker = escapeAttr(`${url.pathname}${url.search}`);
108
+ return `<${wrapper.tag}${cls} data-tina-island="${marker}">${html}</${wrapper.tag}>`;
109
+ }
@@ -0,0 +1,89 @@
1
+ import type { MiddlewareHandler } from 'astro';
2
+ import { adminOrigins } from './internal/admin-origin';
3
+ import {
4
+ type CollectedForm,
5
+ formsStore,
6
+ renderFormPayloadDiv,
7
+ sortByPriority,
8
+ } from './internal/forms-store';
9
+ import { requestStore } from './internal/request-context';
10
+ import { EDIT_COOKIE_HEADER, isEditMode } from './is-edit-mode';
11
+
12
+ const HEAD_CLOSE = '</head>';
13
+
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
+
22
+ const editing = isEditMode(context.request);
23
+ (context.locals as { tinaEdit?: boolean }).tinaEdit = editing;
24
+
25
+ const forms: CollectedForm[] = [];
26
+ return requestStore.run(context.request, () =>
27
+ formsStore.run(forms, async () => {
28
+ const response = await next();
29
+ return editing ? injectEditMode(response, forms) : response;
30
+ })
31
+ );
32
+ };
33
+
34
+ export default onRequest;
35
+
36
+ async function injectEditMode(
37
+ response: Response,
38
+ forms: CollectedForm[]
39
+ ): Promise<Response> {
40
+ const init = editModeInit(response);
41
+ const contentType = response.headers.get('content-type') ?? '';
42
+
43
+ // Redirects / JSON / assets — pass body through and just keep the cookie fresh.
44
+ if (!contentType.includes('text/html')) {
45
+ return new Response(response.body, init);
46
+ }
47
+
48
+ const html = await response.text();
49
+ const headEnd = html.indexOf(HEAD_CLOSE);
50
+ if (headEnd === -1) {
51
+ // No </head> to anchor against — return the body untouched.
52
+ return new Response(html, init);
53
+ }
54
+
55
+ const injection = renderInjection(forms);
56
+ return new Response(
57
+ html.slice(0, headEnd) + injection + html.slice(headEnd),
58
+ init
59
+ );
60
+ }
61
+
62
+ function editModeInit(response: Response): ResponseInit {
63
+ const headers = new Headers(response.headers);
64
+ // Body length changed; let the runtime re-compute it.
65
+ headers.delete('content-length');
66
+ headers.append('Set-Cookie', EDIT_COOKIE_HEADER);
67
+ return { status: response.status, statusText: response.statusText, headers };
68
+ }
69
+
70
+ function renderInjection(forms: CollectedForm[]): string {
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))
75
+ .join('');
76
+ return formDivs + bridgeScript();
77
+ }
78
+
79
+ function bridgeScript(): string {
80
+ const origins = adminOrigins();
81
+ const initArg = origins ? `{adminOrigin:${JSON.stringify(origins)}}` : '';
82
+ return (
83
+ `<script type="module">` +
84
+ `import{init,refreshForms}from"/admin/bridge.js";` +
85
+ `init(${initArg});` +
86
+ `document.addEventListener("astro:page-load",refreshForms);` +
87
+ `</script>`
88
+ );
89
+ }