@tinacms/astro 0.0.1 → 0.2.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 (64) hide show
  1. package/README.md +37 -20
  2. package/dist/bridge-route.d.ts +3 -0
  3. package/dist/bridge-route.js +22 -0
  4. package/dist/bridge.d.ts +7 -1
  5. package/dist/bridge.js +1 -0
  6. package/dist/data.d.ts +16 -0
  7. package/dist/data.js +59 -0
  8. package/dist/experimental.d.ts +10 -0
  9. package/dist/experimental.js +57 -0
  10. package/dist/index.d.ts +36 -1
  11. package/dist/index.js +87 -2
  12. package/dist/integration.d.ts +25 -0
  13. package/dist/integration.js +22 -0
  14. package/dist/internal/escape.d.ts +8 -0
  15. package/dist/internal/forms-store.d.ts +23 -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 +43 -0
  20. package/dist/island-route.js +57 -0
  21. package/dist/middleware.d.ts +20 -0
  22. package/dist/middleware.js +109 -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/package.json +89 -17
  30. package/src/CodeBlockNode.astro +28 -0
  31. package/src/Container.astro +56 -0
  32. package/src/ImageNode.astro +17 -0
  33. package/src/LinkNode.astro +22 -0
  34. package/src/MdxNode.astro +24 -0
  35. package/src/Node.astro +11 -4
  36. package/src/TinaIsland.astro +42 -0
  37. package/src/TinaMarkdown.astro +8 -0
  38. package/src/__tests__/TinaMarkdown.test.ts +112 -0
  39. package/src/__tests__/__snapshots__/TinaMarkdown.test.ts.snap +7 -0
  40. package/src/__tests__/fixtures/FancyHeading.astro +3 -0
  41. package/src/__tests__/fixtures/MyFeature.astro +4 -0
  42. package/src/__tests__/fixtures/basic-kitchen-sink.json +60 -0
  43. package/src/__tests__/fixtures/code-block.json +34 -0
  44. package/src/__tests__/fixtures/leaf-marks.json +199 -0
  45. package/src/__tests__/fixtures/mdx-jsx-flow.json +40 -0
  46. package/src/__tests__/fixtures/mdx-jsx-text.json +53 -0
  47. package/src/__tests__/sanitize.test.ts +75 -0
  48. package/src/bridge-route.ts +33 -0
  49. package/src/bridge.ts +7 -0
  50. package/src/data.ts +97 -0
  51. package/src/experimental.ts +14 -0
  52. package/src/index.ts +54 -0
  53. package/src/integration.ts +49 -0
  54. package/src/internal/escape.ts +15 -0
  55. package/src/internal/forms-store.ts +41 -0
  56. package/src/internal/request-context.ts +23 -0
  57. package/src/is-edit-mode.ts +68 -0
  58. package/src/island-route.ts +110 -0
  59. package/src/middleware.ts +118 -0
  60. package/src/sanitize.ts +64 -0
  61. package/src/tina-field.ts +1 -0
  62. package/src/types.ts +97 -0
  63. package/dist/preview.d.ts +0 -1
  64. package/dist/preview.js +0 -1
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { sanitizeHref, sanitizeImageSrc } from '../sanitize';
3
+
4
+ describe('sanitizeHref', () => {
5
+ it('blocks javascript: scheme', () => {
6
+ expect(sanitizeHref('javascript:alert(1)')).toBe('#');
7
+ expect(sanitizeHref('JavaScript:alert(1)')).toBe('#');
8
+ expect(sanitizeHref(' javascript:alert(1) ')).toBe('#');
9
+ });
10
+
11
+ it('blocks data: and vbscript: schemes', () => {
12
+ expect(sanitizeHref('data:text/html,<script>alert(1)</script>')).toBe('#');
13
+ expect(sanitizeHref('vbscript:msgbox(1)')).toBe('#');
14
+ });
15
+
16
+ it('blocks protocol-relative URLs', () => {
17
+ expect(sanitizeHref('//evil.com/path')).toBe('#');
18
+ });
19
+
20
+ it('allows http(s) URLs', () => {
21
+ expect(sanitizeHref('https://tina.io')).toBe('https://tina.io');
22
+ expect(sanitizeHref('http://tina.io/x')).toBe('http://tina.io/x');
23
+ });
24
+
25
+ it('allows mailto:', () => {
26
+ expect(sanitizeHref('mailto:hi@tina.io')).toBe('mailto:hi@tina.io');
27
+ });
28
+
29
+ it('allows relative, root-relative, and fragment paths', () => {
30
+ expect(sanitizeHref('/about')).toBe('/about');
31
+ expect(sanitizeHref('./relative')).toBe('./relative');
32
+ expect(sanitizeHref('../up')).toBe('../up');
33
+ expect(sanitizeHref('#section')).toBe('#section');
34
+ });
35
+
36
+ it('returns fallback for non-strings, empty, or invalid', () => {
37
+ expect(sanitizeHref(null)).toBe('#');
38
+ expect(sanitizeHref(undefined)).toBe('#');
39
+ expect(sanitizeHref('')).toBe('#');
40
+ expect(sanitizeHref(' ')).toBe('#');
41
+ expect(sanitizeHref(42)).toBe('#');
42
+ expect(sanitizeHref('not a url')).toBe('#');
43
+ });
44
+
45
+ it('honours a custom fallback', () => {
46
+ expect(sanitizeHref('javascript:alert(1)', '/safe')).toBe('/safe');
47
+ });
48
+ });
49
+
50
+ describe('sanitizeImageSrc', () => {
51
+ it('allows http(s) URLs', () => {
52
+ expect(sanitizeImageSrc('https://cdn.tina.io/x.png')).toBe(
53
+ 'https://cdn.tina.io/x.png'
54
+ );
55
+ });
56
+
57
+ it('allows relative paths', () => {
58
+ expect(sanitizeImageSrc('/uploads/x.png')).toBe('/uploads/x.png');
59
+ expect(sanitizeImageSrc('./local.png')).toBe('./local.png');
60
+ expect(sanitizeImageSrc('../up.png')).toBe('../up.png');
61
+ });
62
+
63
+ it('blocks protocol-relative and dangerous schemes', () => {
64
+ expect(sanitizeImageSrc('//evil.com/x.png')).toBe('');
65
+ expect(sanitizeImageSrc('javascript:alert(1)')).toBe('');
66
+ expect(sanitizeImageSrc('data:image/png;base64,xxx')).toBe('');
67
+ });
68
+
69
+ it('returns empty for non-strings, empty, or invalid', () => {
70
+ expect(sanitizeImageSrc(null)).toBe('');
71
+ expect(sanitizeImageSrc(undefined)).toBe('');
72
+ expect(sanitizeImageSrc('')).toBe('');
73
+ expect(sanitizeImageSrc(42)).toBe('');
74
+ });
75
+ });
@@ -0,0 +1,33 @@
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
+ });
package/src/bridge.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Re-exports of `@tinacms/bridge` so Astro projects can pull the bridge from
3
+ * `@tinacms/astro/bridge` instead of installing it separately. The bridge
4
+ * package stays publishable on its own for non-Astro frontends (Hugo, plain
5
+ * HTML, Eleventy).
6
+ */
7
+ export * from '@tinacms/bridge';
package/src/data.ts ADDED
@@ -0,0 +1,97 @@
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
+ import { addMetadata, hashFromQuery } from '@tinacms/bridge/metadata';
28
+ import { readOverlay } from '@tinacms/bridge/preview';
29
+ import { recordForm } from './internal/forms-store';
30
+ import { requestStore } from './internal/request-context';
31
+
32
+ export interface QueryResult<TData> {
33
+ data: TData;
34
+ query: string;
35
+ variables: Record<string, unknown>;
36
+ id: string;
37
+ }
38
+
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
+ type ClientResult<TData> =
43
+ | {
44
+ data: TData;
45
+ query: string;
46
+ variables: Record<string, unknown>;
47
+ }
48
+ | null
49
+ | undefined;
50
+
51
+ export async function requestWithMetadata<TData>(
52
+ source: ClientResult<TData> | Promise<ClientResult<TData>>
53
+ ): Promise<QueryResult<TData>> {
54
+ let result: ClientResult<TData> = null;
55
+ try {
56
+ result = (await source) ?? null;
57
+ } 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.
62
+ console.warn('[@tinacms/astro] client query failed', error);
63
+ }
64
+
65
+ const query = result?.query ?? '';
66
+ const variables = result?.variables ?? {};
67
+ const id = hashFromQuery(JSON.stringify({ query, variables }));
68
+ const data = (result?.data ?? {}) as TData;
69
+
70
+ const request = requestStore.getStore();
71
+ let resolvedData: TData = data;
72
+ if (request) {
73
+ const overlay = await readOverlay<TData>(request, id);
74
+ if (overlay !== undefined) {
75
+ resolvedData = overlay;
76
+ }
77
+ }
78
+
79
+ const enriched = {
80
+ data: addMetadata(id, resolvedData) as TData,
81
+ query,
82
+ variables,
83
+ id,
84
+ };
85
+
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).
89
+ recordForm({
90
+ id,
91
+ query,
92
+ variables,
93
+ data: enriched.data as unknown as object,
94
+ });
95
+
96
+ return enriched;
97
+ }
@@ -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,49 @@
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';
19
+
20
+ 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
+ middlewareOrder?: 'pre' | 'post';
25
+ }
26
+
27
+ export default function tina(
28
+ options: TinaIntegrationOptions = {}
29
+ ): AstroIntegration {
30
+ const { middlewareOrder = 'pre' } = options;
31
+ return {
32
+ name: '@tinacms/astro',
33
+ hooks: {
34
+ 'astro:config:setup': ({ addMiddleware, injectRoute }) => {
35
+ addMiddleware({
36
+ entrypoint: '@tinacms/astro/middleware',
37
+ order: middlewareOrder,
38
+ });
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
+ });
46
+ },
47
+ },
48
+ };
49
+ }
@@ -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,41 @@
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
+ import { AsyncLocalStorage } from 'node:async_hooks';
16
+
17
+ export interface CollectedForm {
18
+ id: string;
19
+ query: string;
20
+ variables: object;
21
+ data: object;
22
+ }
23
+
24
+ const STORE_KEY = Symbol.for('@tinacms/astro/forms-store');
25
+
26
+ type Slot = { [STORE_KEY]?: AsyncLocalStorage<CollectedForm[]> };
27
+ const slot = globalThis as unknown as Slot;
28
+
29
+ export const formsStore: AsyncLocalStorage<CollectedForm[]> = (slot[
30
+ STORE_KEY
31
+ ] ??= new AsyncLocalStorage<CollectedForm[]>());
32
+
33
+ export function recordForm(form: CollectedForm): void {
34
+ const list = formsStore.getStore();
35
+ 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;
40
+ list.push(form);
41
+ }
@@ -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,110 @@
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
+ import type { APIRoute } from 'astro';
27
+ import { experimental_AstroContainer as AstroContainer } from 'astro/container';
28
+ import type { AstroComponentFactory } from 'astro/runtime/server/index.js';
29
+ import { PREVIEW_CONTENT_TYPE } from '@tinacms/bridge/preview';
30
+ import { escapeAttr } from './internal/escape';
31
+
32
+ export interface IslandWrapper {
33
+ tag: string;
34
+ className?: string;
35
+ }
36
+
37
+ export interface IslandConfig {
38
+ /** Resolve the data the component needs. May ignore the search params. */
39
+ fetch: (request: Request, params: URLSearchParams) => Promise<unknown>;
40
+ /** Astro component to render with the fetched data. */
41
+ component: AstroComponentFactory;
42
+ /** Outer element the bridge swaps into — must match the page-side wrapper. */
43
+ wrapper: IslandWrapper;
44
+ /** Map fetched data + URL params to the component's props. */
45
+ propsFromData: (
46
+ data: unknown,
47
+ params: URLSearchParams
48
+ ) => Record<string, unknown>;
49
+ }
50
+
51
+ export type IslandRegistry = Record<string, IslandConfig>;
52
+
53
+ export function experimental_createIslandRoute(
54
+ islands: IslandRegistry
55
+ ): APIRoute {
56
+ return async ({ params, request, url }) => {
57
+ const rejection = rejectIfUnsafe(request);
58
+ if (rejection) return rejection;
59
+
60
+ const island = islands[params.name ?? ''];
61
+ if (!island) {
62
+ return new Response(`Unknown island "${params.name}"`, { status: 404 });
63
+ }
64
+
65
+ 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), {
72
+ headers: {
73
+ 'Content-Type': 'text/html; charset=utf-8',
74
+ 'Cache-Control': 'no-store',
75
+ },
76
+ });
77
+ } catch {
78
+ return new Response('Island render failed', { status: 500 });
79
+ }
80
+ };
81
+ }
82
+
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
+ */
89
+ function rejectIfUnsafe(request: Request): Response | null {
90
+ if (request.method !== 'POST') {
91
+ return new Response('Method Not Allowed', { status: 405 });
92
+ }
93
+ const contentType = request.headers.get('content-type') ?? '';
94
+ if (!contentType.includes(PREVIEW_CONTENT_TYPE)) {
95
+ return new Response('Not Found', { status: 404 });
96
+ }
97
+ const fetchSite = request.headers.get('sec-fetch-site');
98
+ if (fetchSite === 'cross-site' || fetchSite === 'cross-origin') {
99
+ return new Response('Forbidden', { status: 403 });
100
+ }
101
+ return null;
102
+ }
103
+
104
+ function wrapIsland(html: string, wrapper: IslandWrapper, url: URL): string {
105
+ const cls = wrapper.className
106
+ ? ` class="${escapeAttr(wrapper.className)}"`
107
+ : '';
108
+ const marker = escapeAttr(`${url.pathname}${url.search}`);
109
+ return `<${wrapper.tag}${cls} data-tina-island="${marker}">${html}</${wrapper.tag}>`;
110
+ }