@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
package/README.md CHANGED
@@ -5,22 +5,42 @@ The one-stop [TinaCMS](https://tina.io) integration for Astro. Ships:
5
5
  - A vanilla-Astro **rich-text renderer** that mirrors the React `TinaMarkdown` API — same `content` prop, same `components` map shape, but emits pure HTML with no React in the page tree.
6
6
  - The framework-agnostic **`@tinacms/bridge`** re-exported under `@tinacms/astro/bridge` so you only install one package.
7
7
 
8
+ > **Adopting in a new project?** Follow the step-by-step [GETTING_STARTED.md](./GETTING_STARTED.md) — covers install (incl. `@tinacms/cli`), integration wiring, data loaders, island registry, and troubleshooting. The rest of this README is the API reference.
9
+
8
10
  ## Install
9
11
 
10
12
  ```bash
11
- pnpm add @tinacms/astro
13
+ pnpm add @tinacms/astro tinacms
14
+ pnpm add -D @tinacms/cli
12
15
  ```
13
16
 
14
- Requires Astro 5.
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.
15
18
 
16
19
  ## Usage
17
20
 
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
+
23
+ ```ts
24
+ // astro.config.mjs
25
+ import { defineConfig } from 'astro/config';
26
+ import tina from '@tinacms/astro/integration';
27
+
28
+ export default defineConfig({
29
+ integrations: [tina()],
30
+ });
31
+ ```
32
+
33
+ Then load data the same way you would in any TinaCMS project — call the generated client, wrap the result with `requestWithMetadata()`:
34
+
18
35
  ```astro
19
36
  ---
20
- import TinaMarkdown from '@tinacms/astro';
21
- import { tinaField } from '@tinacms/astro/tina-field';
37
+ import TinaMarkdown from '@tinacms/astro/TinaMarkdown.astro';
38
+ import { requestWithMetadata, tinaField } from '@tinacms/astro';
39
+ import client from '../tina/__generated__/client';
22
40
 
23
- const post = await client.queries.post({ relativePath: 'hello.md' });
41
+ const post = await requestWithMetadata(
42
+ client.queries.post({ relativePath: 'hello.md' }),
43
+ );
24
44
  ---
25
45
  <article>
26
46
  <h1 data-tina-field={tinaField(post.data.post, 'title')}>
@@ -32,28 +52,38 @@ const post = await client.queries.post({ relativePath: 'hello.md' });
32
52
  </article>
33
53
  ```
34
54
 
35
- Wire the bridge in your base layout to enable click-to-focus and live preview:
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.)
36
56
 
37
- ```astro
38
- <head>
39
- <script type="application/tina+json" set:html={JSON.stringify(form)} />
40
- <script>
41
- import { init } from '@tinacms/astro/bridge';
42
- init();
43
- </script>
44
- </head>
45
- ```
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
+
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.)
46
70
 
47
71
  ## Subpath exports
48
72
 
49
73
  | Subpath | What it gives you |
50
74
  |---------|-------------------|
51
- | `@tinacms/astro` | `TinaMarkdown` (default) |
75
+ | `@tinacms/astro` | `requestWithMetadata`, `tinaField`, `QueryResult`, and types |
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). |
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 |
52
79
  | `@tinacms/astro/types` | `TinaRichTextContent`, `CustomComponentsMap`, `TinaRichTextNode`, `MdxElement`, `TextElement` |
53
80
  | `@tinacms/astro/sanitize` | `sanitizeHref` / `sanitizeImageSrc` for CMS-supplied URLs |
54
- | `@tinacms/astro/bridge` | `init` and the rest of `@tinacms/bridge` |
81
+ | `@tinacms/astro/bridge` | `init`, `refreshForms`, and the rest of `@tinacms/bridge` |
55
82
  | `@tinacms/astro/tina-field` | `tinaField()` helper |
56
- | `@tinacms/astro/preview` | `readOverlay()` server helper for island refresh endpoints |
83
+ | `@tinacms/astro/is-edit-mode` | `isEditMode(request)` server-side admin-iframe detection |
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` |
86
+ | `@tinacms/astro/experimental` | `experimental_createIslandRoute()` — opt-in helper built on Astro's unstable `experimental_AstroContainer` |
57
87
 
58
88
  ## Custom MDX components
59
89
 
@@ -61,7 +91,7 @@ Register Astro components against the names Tina uses for them in the editor:
61
91
 
62
92
  ```astro
63
93
  ---
64
- import TinaMarkdown from '@tinacms/astro';
94
+ import TinaMarkdown from '@tinacms/astro/TinaMarkdown.astro';
65
95
  import type { CustomComponentsMap } from '@tinacms/astro/types';
66
96
  import BlockQuote from '../components/BlockQuote.astro';
67
97
  import NewsletterSignup from '../components/NewsletterSignup.astro';
@@ -99,8 +129,8 @@ The renderer doesn't emit `data-tina-field` attributes — wrap the call site to
99
129
 
100
130
  ```astro
101
131
  ---
132
+ import TinaMarkdown from '@tinacms/astro/TinaMarkdown.astro';
102
133
  import { tinaField } from '@tinacms/astro/tina-field';
103
- import TinaMarkdown from '@tinacms/astro';
104
134
  ---
105
135
  <div data-tina-field={tinaField(post.data.post, '_body')}>
106
136
  <TinaMarkdown content={post.data.post._body} components={components} />
package/dist/bridge.d.ts CHANGED
@@ -1 +1,7 @@
1
- export * from "../src/bridge"
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/dist/bridge.js CHANGED
@@ -1 +1,2 @@
1
+ // src/bridge.ts
1
2
  export * from "@tinacms/bridge";
package/dist/data.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ export interface QueryResult<TData> {
2
+ data: TData;
3
+ query: string;
4
+ variables: Record<string, unknown>;
5
+ id: string;
6
+ }
7
+ type ClientResult<TData> = {
8
+ data: TData;
9
+ query: string;
10
+ variables: Record<string, unknown>;
11
+ } | null | undefined;
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>>;
19
+ export {};
package/dist/data.js ADDED
@@ -0,0 +1,64 @@
1
+ // src/data.ts
2
+ import { addMetadata, hashFromQuery } from "@tinacms/bridge/metadata";
3
+ import { readOverlay } from "@tinacms/bridge/preview";
4
+
5
+ // src/internal/forms-store.ts
6
+ import { AsyncLocalStorage } from "node:async_hooks";
7
+ var STORE_KEY = Symbol.for("@tinacms/astro/forms-store");
8
+ var slot = globalThis;
9
+ var formsStore = slot[STORE_KEY] ??= new AsyncLocalStorage();
10
+ function recordForm(form) {
11
+ const list = formsStore.getStore();
12
+ if (!list) 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
+ }
18
+ list.push(form);
19
+ }
20
+
21
+ // src/internal/request-context.ts
22
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
23
+ var STORE_KEY2 = Symbol.for("@tinacms/astro/request-context");
24
+ var slot2 = globalThis;
25
+ var requestStore = slot2[STORE_KEY2] ??= new AsyncLocalStorage2();
26
+
27
+ // src/data.ts
28
+ async function requestWithMetadata(source, options) {
29
+ let result = null;
30
+ try {
31
+ result = await source ?? null;
32
+ } catch (error) {
33
+ console.warn("[@tinacms/astro] client query failed", error);
34
+ }
35
+ const query = result?.query ?? "";
36
+ const variables = result?.variables ?? {};
37
+ const id = hashFromQuery(JSON.stringify({ query, variables }));
38
+ const data = result?.data ?? {};
39
+ const request = requestStore.getStore();
40
+ let resolvedData = data;
41
+ if (request) {
42
+ const overlay = await readOverlay(request, id);
43
+ if (overlay !== void 0) {
44
+ resolvedData = overlay;
45
+ }
46
+ }
47
+ const enriched = {
48
+ data: addMetadata(id, resolvedData),
49
+ query,
50
+ variables,
51
+ id
52
+ };
53
+ recordForm({
54
+ id,
55
+ query,
56
+ variables,
57
+ data: enriched.data,
58
+ priority: options?.priority
59
+ });
60
+ return enriched;
61
+ }
62
+ export {
63
+ requestWithMetadata
64
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
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 { experimental_createIslandRoute, type IslandConfig, type IslandRegistry, } from './island-route';
@@ -0,0 +1,87 @@
1
+ // src/island-route.ts
2
+ import { PREVIEW_CONTENT_TYPE, PRIME_HEADER } from "@tinacms/bridge/preview";
3
+ import { experimental_AstroContainer as AstroContainer } from "astro/container";
4
+
5
+ // src/internal/escape.ts
6
+ function escapeAttr(s) {
7
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
8
+ }
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
+
30
+ // src/island-route.ts
31
+ function experimental_createIslandRoute(islands) {
32
+ return async ({ params, request, url }) => {
33
+ const rejection = rejectIfUnsafe(request);
34
+ if (rejection) return rejection;
35
+ const island = islands[params.name ?? ""];
36
+ if (!island) {
37
+ return new Response(`Unknown island "${params.name}"`, { status: 404 });
38
+ }
39
+ const priming = request.headers.get(PRIME_HEADER) !== null;
40
+ try {
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, {
54
+ headers: {
55
+ "Content-Type": "text/html; charset=utf-8",
56
+ "Cache-Control": "no-store"
57
+ }
58
+ });
59
+ } catch {
60
+ return new Response("Island render failed", { status: 500 });
61
+ }
62
+ };
63
+ }
64
+ function rejectIfUnsafe(request) {
65
+ if (request.method !== "POST") {
66
+ return new Response("Method Not Allowed", { status: 405 });
67
+ }
68
+ const contentType = request.headers.get("content-type") ?? "";
69
+ if (!contentType.includes(PREVIEW_CONTENT_TYPE)) {
70
+ return new Response("Not Found", { status: 404 });
71
+ }
72
+ if (request.headers.get("sec-fetch-site") === "cross-site") {
73
+ return new Response("Forbidden", { status: 403 });
74
+ }
75
+ return null;
76
+ }
77
+ function renderFormPayloads(forms) {
78
+ return sortByPriority(forms).map((form) => renderFormPayloadDiv(form, form.priority === "primary")).join("");
79
+ }
80
+ function wrapIsland(html, wrapper, url) {
81
+ const cls = wrapper.className ? ` class="${escapeAttr(wrapper.className)}"` : "";
82
+ const marker = escapeAttr(`${url.pathname}${url.search}`);
83
+ return `<${wrapper.tag}${cls} data-tina-island="${marker}">${html}</${wrapper.tag}>`;
84
+ }
85
+ export {
86
+ experimental_createIslandRoute
87
+ };
package/dist/index.d.ts CHANGED
@@ -1 +1,36 @@
1
- export * from "../src/index"
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
+ export { requestWithMetadata, type QueryResult } from './data';
15
+ export { tinaField } from './tina-field';
16
+ export type { AstroComponent, CustomComponentsMap, MdxElement, TextElement, TinaRichTextContent, TinaRichTextNode, TinaRichTextRoot, } from './types';
17
+ /**
18
+ * Typed shape of `<TinaMarkdown content={...} components={...} />` for tools
19
+ * that resolve through the `types` / `default` export condition rather than
20
+ * the `astro` condition. The actual component is `src/TinaMarkdown.astro`;
21
+ * this placeholder throws if invoked because the only legitimate caller is
22
+ * Astro's component pipeline, which reaches the .astro file directly.
23
+ *
24
+ * The intersection of `AstroComponentFactory` (Astro's component-validity
25
+ * check) and a typed prop signature (TinaMarkdown's actual API) gives the
26
+ * Astro language server enough shape to both recognise this as a renderable
27
+ * component AND offer prop completions / type errors at the call site.
28
+ */
29
+ type TinaMarkdownComponent = AstroComponentFactory & {
30
+ (props: {
31
+ content: TinaRichTextContent;
32
+ components?: CustomComponentsMap;
33
+ }): unknown;
34
+ };
35
+ declare const TinaMarkdownPlaceholder: TinaMarkdownComponent;
36
+ export default TinaMarkdownPlaceholder;
package/dist/index.js CHANGED
@@ -1,4 +1,94 @@
1
- const TinaMarkdown$1 = TinaMarkdown;
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
5
+ var __copyProps = (to, from, except, desc) => {
6
+ if (from && typeof from === "object" || typeof from === "function") {
7
+ for (let key of __getOwnPropNames(from))
8
+ if (!__hasOwnProp.call(to, key) && key !== except)
9
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
10
+ }
11
+ return to;
12
+ };
13
+ var __reExport = (target, mod, secondTarget) => (__copyProps(target, mod, "default"), secondTarget && __copyProps(secondTarget, mod, "default"));
14
+
15
+ // src/data.ts
16
+ import { addMetadata, hashFromQuery } from "@tinacms/bridge/metadata";
17
+ import { readOverlay } from "@tinacms/bridge/preview";
18
+
19
+ // src/internal/forms-store.ts
20
+ import { AsyncLocalStorage } from "node:async_hooks";
21
+ var STORE_KEY = Symbol.for("@tinacms/astro/forms-store");
22
+ var slot = globalThis;
23
+ var formsStore = slot[STORE_KEY] ??= new AsyncLocalStorage();
24
+ function recordForm(form) {
25
+ const list = formsStore.getStore();
26
+ if (!list) 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
+ }
32
+ list.push(form);
33
+ }
34
+
35
+ // src/internal/request-context.ts
36
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
37
+ var STORE_KEY2 = Symbol.for("@tinacms/astro/request-context");
38
+ var slot2 = globalThis;
39
+ var requestStore = slot2[STORE_KEY2] ??= new AsyncLocalStorage2();
40
+
41
+ // src/data.ts
42
+ async function requestWithMetadata(source, options) {
43
+ let result = null;
44
+ try {
45
+ result = await source ?? null;
46
+ } catch (error) {
47
+ console.warn("[@tinacms/astro] client query failed", error);
48
+ }
49
+ const query = result?.query ?? "";
50
+ const variables = result?.variables ?? {};
51
+ const id = hashFromQuery(JSON.stringify({ query, variables }));
52
+ const data = result?.data ?? {};
53
+ const request = requestStore.getStore();
54
+ let resolvedData = data;
55
+ if (request) {
56
+ const overlay = await readOverlay(request, id);
57
+ if (overlay !== void 0) {
58
+ resolvedData = overlay;
59
+ }
60
+ }
61
+ const enriched = {
62
+ data: addMetadata(id, resolvedData),
63
+ query,
64
+ variables,
65
+ id
66
+ };
67
+ recordForm({
68
+ id,
69
+ query,
70
+ variables,
71
+ data: enriched.data,
72
+ priority: options?.priority
73
+ });
74
+ return enriched;
75
+ }
76
+
77
+ // src/tina-field.ts
78
+ var tina_field_exports = {};
79
+ __reExport(tina_field_exports, tina_field_star);
80
+ import * as tina_field_star from "@tinacms/bridge/tina-field";
81
+
82
+ // src/index.ts
83
+ var TinaMarkdownPlaceholder = () => {
84
+ throw new Error(
85
+ "[@tinacms/astro] TinaMarkdown must be loaded through Astro's pipeline. Add `tina()` from `@tinacms/astro/integration` to your astro.config integrations, or import directly from `@tinacms/astro/TinaMarkdown.astro`."
86
+ );
87
+ };
88
+ var index_default = TinaMarkdownPlaceholder;
89
+ var export_tinaField = tina_field_exports.tinaField;
2
90
  export {
3
- TinaMarkdown$1 as default
91
+ index_default as default,
92
+ requestWithMetadata,
93
+ export_tinaField as tinaField
4
94
  };
@@ -0,0 +1,5 @@
1
+ import type { AstroIntegration } from 'astro';
2
+ export interface TinaIntegrationOptions {
3
+ middlewareOrder?: 'pre' | 'post';
4
+ }
5
+ export default function tina(options?: TinaIntegrationOptions): AstroIntegration;
@@ -0,0 +1,69 @@
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";
7
+ function tina(options = {}) {
8
+ const { middlewareOrder = "pre" } = options;
9
+ let clientDir;
10
+ return {
11
+ name: "@tinacms/astro",
12
+ hooks: {
13
+ "astro:config:setup": ({ addMiddleware, updateConfig }) => {
14
+ addMiddleware({
15
+ entrypoint: "@tinacms/astro/middleware",
16
+ order: middlewareOrder
17
+ });
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);
26
+ }
27
+ }
28
+ };
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
+ }
67
+ export {
68
+ tina as default
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;
@@ -0,0 +1,8 @@
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 declare function escapeAttr(s: string): string;
@@ -0,0 +1,12 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ export interface CollectedForm {
3
+ id: string;
4
+ query: string;
5
+ variables: object;
6
+ data: object;
7
+ priority?: 'primary';
8
+ }
9
+ export declare const formsStore: AsyncLocalStorage<CollectedForm[]>;
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;
@@ -0,0 +1,16 @@
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
+ export declare const requestStore: AsyncLocalStorage<Request>;
@@ -0,0 +1,32 @@
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 declare const EDIT_COOKIE = "__tina_edit";
23
+ /**
24
+ * Set-Cookie header value the middleware writes on every edit-mode
25
+ * response. Refreshing on each response keeps long editing sessions
26
+ * sticky and short Max-Age limits the blast radius if a cookie lingers.
27
+ * The `Sec-Fetch-Dest: iframe` gate in `isEditMode` blocks the cookie
28
+ * from triggering edit mode on top-level visits.
29
+ */
30
+ export declare const EDIT_COOKIE_HEADER = "__tina_edit=1; Path=/; SameSite=Strict; Max-Age=3600";
31
+ export declare function isEditMode(request: Request): boolean;
32
+ export declare function readCookie(request: Request, name: string): string | null;
@@ -0,0 +1,37 @@
1
+ // src/is-edit-mode.ts
2
+ var EDIT_COOKIE = "__tina_edit";
3
+ var EDIT_COOKIE_HEADER = `${EDIT_COOKIE}=1; Path=/; SameSite=Strict; Max-Age=3600`;
4
+ function isEditMode(request) {
5
+ const url = new URL(request.url);
6
+ if (url.searchParams.get("tina-edit") === "1") return true;
7
+ const dest = request.headers.get("Sec-Fetch-Dest");
8
+ if (dest !== "iframe") return false;
9
+ const referer = request.headers.get("Referer");
10
+ if (referer) {
11
+ try {
12
+ const refererUrl = new URL(referer);
13
+ if (refererUrl.origin === url.origin && refererUrl.pathname.startsWith("/admin/")) {
14
+ return true;
15
+ }
16
+ } catch {
17
+ }
18
+ }
19
+ return readCookie(request, EDIT_COOKIE) === "1";
20
+ }
21
+ function readCookie(request, name) {
22
+ const header = request.headers.get("Cookie");
23
+ if (!header) return null;
24
+ for (const pair of header.split(";")) {
25
+ const eq = pair.indexOf("=");
26
+ if (eq === -1) continue;
27
+ const key = pair.slice(0, eq).trim();
28
+ if (key === name) return pair.slice(eq + 1).trim();
29
+ }
30
+ return null;
31
+ }
32
+ export {
33
+ EDIT_COOKIE,
34
+ EDIT_COOKIE_HEADER,
35
+ isEditMode,
36
+ readCookie
37
+ };