@tinacms/astro 0.0.0-00ff25a-20260605030238

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/LICENSE +176 -0
  2. package/README.md +178 -0
  3. package/dist/bridge.d.ts +7 -0
  4. package/dist/bridge.js +2 -0
  5. package/dist/data.d.ts +19 -0
  6. package/dist/data.js +64 -0
  7. package/dist/data.test-d.d.ts +1 -0
  8. package/dist/experimental.d.ts +10 -0
  9. package/dist/experimental.js +87 -0
  10. package/dist/index.d.ts +36 -0
  11. package/dist/index.js +94 -0
  12. package/dist/integration.d.ts +12 -0
  13. package/dist/integration.js +93 -0
  14. package/dist/internal/admin-origin.d.ts +6 -0
  15. package/dist/internal/escape.d.ts +8 -0
  16. package/dist/internal/forms-store.d.ts +12 -0
  17. package/dist/internal/request-context.d.ts +16 -0
  18. package/dist/is-edit-mode.d.ts +32 -0
  19. package/dist/is-edit-mode.js +37 -0
  20. package/dist/island-route.d.ts +18 -0
  21. package/dist/island-route.js +87 -0
  22. package/dist/middleware.d.ts +3 -0
  23. package/dist/middleware.js +123 -0
  24. package/dist/sanitize.d.ts +12 -0
  25. package/dist/sanitize.js +41 -0
  26. package/dist/tina-field.d.ts +1 -0
  27. package/dist/tina-field.js +2 -0
  28. package/dist/types.d.ts +92 -0
  29. package/dist/types.js +0 -0
  30. package/dist/vite.d.ts +21 -0
  31. package/dist/vite.js +24 -0
  32. package/package.json +151 -0
  33. package/src/CodeBlockNode.astro +28 -0
  34. package/src/Container.astro +56 -0
  35. package/src/ImageNode.astro +17 -0
  36. package/src/Leaf.astro +42 -0
  37. package/src/LinkNode.astro +22 -0
  38. package/src/MdxNode.astro +24 -0
  39. package/src/Node.astro +73 -0
  40. package/src/TinaIsland.astro +44 -0
  41. package/src/TinaMarkdown.astro +36 -0
  42. package/src/__tests__/IslandStub.astro +8 -0
  43. package/src/__tests__/TinaIsland.test.ts +60 -0
  44. package/src/__tests__/TinaMarkdown.test.ts +112 -0
  45. package/src/__tests__/__snapshots__/TinaMarkdown.test.ts.snap +7 -0
  46. package/src/__tests__/fixtures/FancyHeading.astro +3 -0
  47. package/src/__tests__/fixtures/MyFeature.astro +4 -0
  48. package/src/__tests__/fixtures/basic-kitchen-sink.json +60 -0
  49. package/src/__tests__/fixtures/code-block.json +34 -0
  50. package/src/__tests__/fixtures/leaf-marks.json +199 -0
  51. package/src/__tests__/fixtures/mdx-jsx-flow.json +40 -0
  52. package/src/__tests__/fixtures/mdx-jsx-text.json +53 -0
  53. package/src/__tests__/forms-store.test.ts +70 -0
  54. package/src/__tests__/integration.test.ts +228 -0
  55. package/src/__tests__/island-route.test.ts +119 -0
  56. package/src/__tests__/middleware.test.ts +102 -0
  57. package/src/__tests__/sanitize.test.ts +75 -0
  58. package/src/__tests__/vite.test.ts +67 -0
  59. package/src/bridge.ts +7 -0
  60. package/src/data.test-d.ts +53 -0
  61. package/src/data.ts +73 -0
  62. package/src/experimental.ts +14 -0
  63. package/src/index.ts +54 -0
  64. package/src/integration.ts +162 -0
  65. package/src/internal/admin-origin.ts +19 -0
  66. package/src/internal/escape.ts +15 -0
  67. package/src/internal/forms-store.ts +50 -0
  68. package/src/internal/request-context.ts +23 -0
  69. package/src/is-edit-mode.ts +68 -0
  70. package/src/island-route.ts +109 -0
  71. package/src/middleware.ts +89 -0
  72. package/src/sanitize.ts +64 -0
  73. package/src/tina-field.ts +1 -0
  74. package/src/types.ts +97 -0
  75. package/src/vite.ts +40 -0
package/dist/index.js ADDED
@@ -0,0 +1,94 @@
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;
90
+ export {
91
+ index_default as default,
92
+ requestWithMetadata,
93
+ export_tinaField as tinaField
94
+ };
@@ -0,0 +1,12 @@
1
+ import type { AstroIntegration } from 'astro';
2
+ export interface TinaIntegrationOptions {
3
+ middlewareOrder?: 'pre' | 'post';
4
+ /**
5
+ * Force the Cloudflare Workers (workerd) `import.meta.url` workaround on or
6
+ * off. When omitted, it is applied automatically whenever the
7
+ * `@astrojs/cloudflare` adapter is detected. Set `false` to opt out, or
8
+ * `true` to force it for a custom Cloudflare setup.
9
+ */
10
+ cloudflareWorkers?: boolean;
11
+ }
12
+ export default function tina(options?: TinaIntegrationOptions): AstroIntegration;
@@ -0,0 +1,93 @@
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
+ var CLOUDFLARE_ADAPTER_NAME = "@astrojs/cloudflare";
8
+ var WORKERD_IMPORT_META_URL = JSON.stringify("file:///worker.mjs");
9
+ var SERVER_ENVIRONMENTS = /* @__PURE__ */ new Set(["ssr", "astro"]);
10
+ function tina(options = {}) {
11
+ const { middlewareOrder = "pre" } = options;
12
+ let clientDir;
13
+ let isCloudflareAdapter = false;
14
+ return {
15
+ name: "@tinacms/astro",
16
+ hooks: {
17
+ "astro:config:setup": ({ addMiddleware, updateConfig }) => {
18
+ addMiddleware({
19
+ entrypoint: "@tinacms/astro/middleware",
20
+ order: middlewareOrder
21
+ });
22
+ updateConfig({
23
+ vite: {
24
+ plugins: [
25
+ bridgeDevPlugin(),
26
+ cloudflareImportMetaUrlPlugin(() => isCloudflareAdapter)
27
+ ]
28
+ }
29
+ });
30
+ },
31
+ "astro:config:done": ({ config }) => {
32
+ clientDir = config.build.client;
33
+ isCloudflareAdapter = options.cloudflareWorkers ?? config.adapter?.name === CLOUDFLARE_ADAPTER_NAME;
34
+ },
35
+ "astro:build:done": ({ logger }) => {
36
+ if (!clientDir) return;
37
+ emitBridgeAsset(fileURLToPath(new URL("admin/", clientDir)), logger);
38
+ }
39
+ }
40
+ };
41
+ }
42
+ function resolveBridge() {
43
+ return createRequire(import.meta.url).resolve("@tinacms/bridge");
44
+ }
45
+ function bridgeDevPlugin() {
46
+ return {
47
+ name: "@tinacms/astro:bridge-dev",
48
+ apply: "serve",
49
+ configureServer(server) {
50
+ server.middlewares.use((req, res, next) => {
51
+ const path = (req.url || "").split("?")[0];
52
+ if (path !== BRIDGE_ROUTE) {
53
+ next();
54
+ return;
55
+ }
56
+ try {
57
+ const body = readFileSync(resolveBridge());
58
+ res.setHeader("Content-Type", "text/javascript");
59
+ res.setHeader("Cache-Control", "no-cache");
60
+ res.end(body);
61
+ } catch (error) {
62
+ res.statusCode = 500;
63
+ res.end(`/* @tinacms/astro: bridge unavailable: ${error} */`);
64
+ }
65
+ });
66
+ }
67
+ };
68
+ }
69
+ function cloudflareImportMetaUrlPlugin(isCloudflare) {
70
+ const define = { "import.meta.url": WORKERD_IMPORT_META_URL };
71
+ return {
72
+ name: "@tinacms/astro:cloudflare-import-meta-url",
73
+ // No `apply`: the define is needed in both the build (ssr) and dev envs.
74
+ configEnvironment(name) {
75
+ if (!isCloudflare()) return;
76
+ if (!SERVER_ENVIRONMENTS.has(name)) return;
77
+ return { define };
78
+ }
79
+ };
80
+ }
81
+ function emitBridgeAsset(adminDir, logger) {
82
+ try {
83
+ mkdirSync(adminDir, { recursive: true });
84
+ copyFileSync(resolveBridge(), join(adminDir, "bridge.js"));
85
+ } catch (error) {
86
+ logger.warn(
87
+ `could not emit admin/bridge.js \u2014 visual editing will not load: ${error}`
88
+ );
89
+ }
90
+ }
91
+ export {
92
+ tina as default
93
+ };
@@ -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
+ };
@@ -0,0 +1,18 @@
1
+ import type { APIRoute } from 'astro';
2
+ import type { AstroComponentFactory } from 'astro/runtime/server/index.js';
3
+ export interface IslandWrapper {
4
+ tag: string;
5
+ className?: string;
6
+ }
7
+ export interface IslandConfig {
8
+ /** Resolve the data the component needs. May ignore the search params. */
9
+ fetch: (request: Request, params: URLSearchParams) => Promise<unknown>;
10
+ /** Astro component to render with the fetched data. */
11
+ component: AstroComponentFactory;
12
+ /** Outer element the bridge swaps into — must match the page-side wrapper. */
13
+ wrapper: IslandWrapper;
14
+ /** Map fetched data + URL params to the component's props. */
15
+ propsFromData: (data: unknown, params: URLSearchParams) => Record<string, unknown>;
16
+ }
17
+ export type IslandRegistry = Record<string, IslandConfig>;
18
+ export declare function experimental_createIslandRoute(islands: IslandRegistry): APIRoute;
@@ -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
+ };
@@ -0,0 +1,3 @@
1
+ import type { MiddlewareHandler } from 'astro';
2
+ export declare const onRequest: MiddlewareHandler;
3
+ export default onRequest;
@@ -0,0 +1,123 @@
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
+
13
+ // src/internal/escape.ts
14
+ function escapeAttr(s) {
15
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
16
+ }
17
+
18
+ // src/internal/forms-store.ts
19
+ var STORE_KEY = Symbol.for("@tinacms/astro/forms-store");
20
+ var slot = globalThis;
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
+ }
30
+
31
+ // src/internal/request-context.ts
32
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
33
+ var STORE_KEY2 = Symbol.for("@tinacms/astro/request-context");
34
+ var slot2 = globalThis;
35
+ var requestStore = slot2[STORE_KEY2] ??= new AsyncLocalStorage2();
36
+
37
+ // src/is-edit-mode.ts
38
+ var EDIT_COOKIE = "__tina_edit";
39
+ var EDIT_COOKIE_HEADER = `${EDIT_COOKIE}=1; Path=/; SameSite=Strict; Max-Age=3600`;
40
+ function isEditMode(request) {
41
+ const url = new URL(request.url);
42
+ if (url.searchParams.get("tina-edit") === "1") return true;
43
+ const dest = request.headers.get("Sec-Fetch-Dest");
44
+ if (dest !== "iframe") return false;
45
+ const referer = request.headers.get("Referer");
46
+ if (referer) {
47
+ try {
48
+ const refererUrl = new URL(referer);
49
+ if (refererUrl.origin === url.origin && refererUrl.pathname.startsWith("/admin/")) {
50
+ return true;
51
+ }
52
+ } catch {
53
+ }
54
+ }
55
+ return readCookie(request, EDIT_COOKIE) === "1";
56
+ }
57
+ function readCookie(request, name) {
58
+ const header = request.headers.get("Cookie");
59
+ if (!header) return null;
60
+ for (const pair of header.split(";")) {
61
+ const eq = pair.indexOf("=");
62
+ if (eq === -1) continue;
63
+ const key = pair.slice(0, eq).trim();
64
+ if (key === name) return pair.slice(eq + 1).trim();
65
+ }
66
+ return null;
67
+ }
68
+
69
+ // src/middleware.ts
70
+ var HEAD_CLOSE = "</head>";
71
+ var onRequest = (context, next) => {
72
+ if (context.isPrerendered) {
73
+ context.locals.tinaEdit = false;
74
+ return next();
75
+ }
76
+ const editing = isEditMode(context.request);
77
+ context.locals.tinaEdit = editing;
78
+ const forms = [];
79
+ return requestStore.run(
80
+ context.request,
81
+ () => formsStore.run(forms, async () => {
82
+ const response = await next();
83
+ return editing ? injectEditMode(response, forms) : response;
84
+ })
85
+ );
86
+ };
87
+ var middleware_default = onRequest;
88
+ async function injectEditMode(response, forms) {
89
+ const init = editModeInit(response);
90
+ const contentType = response.headers.get("content-type") ?? "";
91
+ if (!contentType.includes("text/html")) {
92
+ return new Response(response.body, init);
93
+ }
94
+ const html = await response.text();
95
+ const headEnd = html.indexOf(HEAD_CLOSE);
96
+ if (headEnd === -1) {
97
+ return new Response(html, init);
98
+ }
99
+ const injection = renderInjection(forms);
100
+ return new Response(
101
+ html.slice(0, headEnd) + injection + html.slice(headEnd),
102
+ init
103
+ );
104
+ }
105
+ function editModeInit(response) {
106
+ const headers = new Headers(response.headers);
107
+ headers.delete("content-length");
108
+ headers.append("Set-Cookie", EDIT_COOKIE_HEADER);
109
+ return { status: response.status, statusText: response.statusText, headers };
110
+ }
111
+ function renderInjection(forms) {
112
+ const formDivs = sortByPriority(forms).map((form, i) => renderFormPayloadDiv(form, i === 0)).join("");
113
+ return formDivs + bridgeScript();
114
+ }
115
+ function bridgeScript() {
116
+ const origins = adminOrigins();
117
+ const initArg = origins ? `{adminOrigin:${JSON.stringify(origins)}}` : "";
118
+ return `<script type="module">import{init,refreshForms}from"/admin/bridge.js";init(${initArg});document.addEventListener("astro:page-load",refreshForms);</script>`;
119
+ }
120
+ export {
121
+ middleware_default as default,
122
+ onRequest
123
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Sanitizes a CMS-supplied href, returning a safe URL or the fallback.
3
+ * Blocks dangerous schemes (javascript:, data:, vbscript:) and
4
+ * protocol-relative URLs (//evil.com). Allows relative paths, http(s),
5
+ * and mailto:.
6
+ */
7
+ export declare function sanitizeHref(value: unknown, fallback?: string): string;
8
+ /**
9
+ * Validates a CMS-supplied image src, returning the src string if safe or ''
10
+ * if it is empty, not a string, or uses a non-http(s)/relative scheme.
11
+ */
12
+ export declare function sanitizeImageSrc(src: unknown): string;
@@ -0,0 +1,41 @@
1
+ // src/sanitize.ts
2
+ function sanitizeHref(value, fallback = "#") {
3
+ if (typeof value !== "string") return fallback;
4
+ const trimmed = value.trim();
5
+ if (!trimmed) return fallback;
6
+ const lower = trimmed.toLowerCase();
7
+ if (lower.startsWith("javascript:") || lower.startsWith("data:") || lower.startsWith("vbscript:")) {
8
+ return fallback;
9
+ }
10
+ if (trimmed.startsWith("/") && !trimmed.startsWith("//") || trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("#")) {
11
+ return trimmed;
12
+ }
13
+ try {
14
+ const url = new URL(trimmed);
15
+ if (url.protocol === "http:" || url.protocol === "https:" || url.protocol === "mailto:") {
16
+ return trimmed;
17
+ }
18
+ } catch {
19
+ return fallback;
20
+ }
21
+ return fallback;
22
+ }
23
+ function sanitizeImageSrc(src) {
24
+ if (typeof src !== "string") return "";
25
+ const trimmed = src.trim();
26
+ if (!trimmed) return "";
27
+ if (trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("/") && !trimmed.startsWith("//")) {
28
+ return trimmed;
29
+ }
30
+ try {
31
+ const url = new URL(trimmed);
32
+ if (url.protocol === "http:" || url.protocol === "https:") return trimmed;
33
+ } catch {
34
+ return "";
35
+ }
36
+ return "";
37
+ }
38
+ export {
39
+ sanitizeHref,
40
+ sanitizeImageSrc
41
+ };
@@ -0,0 +1 @@
1
+ export * from '@tinacms/bridge/tina-field';
@@ -0,0 +1,2 @@
1
+ // src/tina-field.ts
2
+ export * from "@tinacms/bridge/tina-field";