@tinacms/astro 0.2.0 → 0.4.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 +19 -6
- package/dist/data.d.ts +7 -4
- package/dist/data.js +8 -3
- package/dist/data.test-d.d.ts +1 -0
- package/dist/experimental.js +39 -9
- package/dist/index.js +8 -3
- package/dist/integration.d.ts +0 -20
- package/dist/integration.js +52 -5
- package/dist/internal/admin-origin.d.ts +6 -0
- package/dist/internal/forms-store.d.ts +3 -14
- package/dist/island-route.d.ts +0 -25
- package/dist/island-route.js +39 -9
- package/dist/middleware.d.ts +0 -17
- package/dist/middleware.js +26 -12
- package/dist/vite.d.ts +21 -0
- package/dist/vite.js +24 -0
- package/package.json +12 -13
- package/src/TinaIsland.astro +25 -23
- package/src/__tests__/IslandStub.astro +8 -0
- package/src/__tests__/TinaIsland.test.ts +60 -0
- package/src/__tests__/forms-store.test.ts +70 -0
- package/src/__tests__/integration.test.ts +124 -0
- package/src/__tests__/island-route.test.ts +119 -0
- package/src/__tests__/middleware.test.ts +102 -0
- package/src/__tests__/vite.test.ts +67 -0
- package/src/data.test-d.ts +53 -0
- package/src/data.ts +13 -37
- package/src/integration.ts +74 -29
- package/src/internal/admin-origin.ts +19 -0
- package/src/internal/forms-store.ts +27 -18
- package/src/island-route.ts +37 -38
- package/src/middleware.ts +19 -48
- package/src/vite.ts +40 -0
- package/dist/bridge-route.d.ts +0 -3
- package/dist/bridge-route.js +0 -22
- package/src/bridge-route.ts +0 -33
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
|
|
17
|
+
Requires Astro 5 or 6 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
|
|
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="/
|
|
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
|
|
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
|
|
@@ -148,7 +161,7 @@ CMS-supplied URLs in `a` and `img` nodes pass through `sanitizeHref` / `sanitize
|
|
|
148
161
|
|
|
149
162
|
## Testing
|
|
150
163
|
|
|
151
|
-
Tests use Astro
|
|
164
|
+
Tests use Astro's [`experimental_AstroContainer`](https://docs.astro.build/en/reference/container-reference/). Fixtures are synced from `@tinacms/mdx`'s parser test corpus so renderer assertions track real editor output.
|
|
152
165
|
|
|
153
166
|
```bash
|
|
154
167
|
pnpm test
|
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
|
|
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
|
-
|
|
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 {};
|
package/dist/experimental.js
CHANGED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/integration.d.ts
CHANGED
|
@@ -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;
|
package/dist/integration.js
CHANGED
|
@@ -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,
|
|
13
|
+
"astro:config:setup": ({ addMiddleware, updateConfig }) => {
|
|
8
14
|
addMiddleware({
|
|
9
15
|
entrypoint: "@tinacms/astro/middleware",
|
|
10
16
|
order: middlewareOrder
|
|
11
17
|
});
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
};
|
|
@@ -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;
|
package/dist/island-route.d.ts
CHANGED
|
@@ -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 {
|
package/dist/island-route.js
CHANGED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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/middleware.d.ts
CHANGED
|
@@ -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;
|
package/dist/middleware.js
CHANGED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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"/
|
|
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
|
+
};
|