@tinacms/astro 0.4.0 → 0.4.1

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.
@@ -1,5 +1,12 @@
1
1
  import type { AstroIntegration } from 'astro';
2
2
  export interface TinaIntegrationOptions {
3
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;
4
11
  }
5
12
  export default function tina(options?: TinaIntegrationOptions): AstroIntegration;
@@ -4,9 +4,13 @@ import { createRequire } from "node:module";
4
4
  import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
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"]);
7
10
  function tina(options = {}) {
8
11
  const { middlewareOrder = "pre" } = options;
9
12
  let clientDir;
13
+ let isCloudflareAdapter = false;
10
14
  return {
11
15
  name: "@tinacms/astro",
12
16
  hooks: {
@@ -15,10 +19,18 @@ function tina(options = {}) {
15
19
  entrypoint: "@tinacms/astro/middleware",
16
20
  order: middlewareOrder
17
21
  });
18
- updateConfig({ vite: { plugins: [bridgeDevPlugin()] } });
22
+ updateConfig({
23
+ vite: {
24
+ plugins: [
25
+ bridgeDevPlugin(),
26
+ cloudflareImportMetaUrlPlugin(() => isCloudflareAdapter)
27
+ ]
28
+ }
29
+ });
19
30
  },
20
31
  "astro:config:done": ({ config }) => {
21
32
  clientDir = config.build.client;
33
+ isCloudflareAdapter = options.cloudflareWorkers ?? config.adapter?.name === CLOUDFLARE_ADAPTER_NAME;
22
34
  },
23
35
  "astro:build:done": ({ logger }) => {
24
36
  if (!clientDir) return;
@@ -54,6 +66,18 @@ function bridgeDevPlugin() {
54
66
  }
55
67
  };
56
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
+ }
57
81
  function emitBridgeAsset(adminDir, logger) {
58
82
  try {
59
83
  mkdirSync(adminDir, { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinacms/astro",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "main": "src/TinaMarkdown.astro",
6
6
  "types": "dist/index.d.ts",
@@ -12,11 +12,11 @@ type ConfigSetupArg = Parameters<NonNullable<Hooks['astro:config:setup']>>[0];
12
12
  type ConfigDoneArg = Parameters<NonNullable<Hooks['astro:config:done']>>[0];
13
13
  type BuildDoneArg = Parameters<NonNullable<Hooks['astro:build:done']>>[0];
14
14
 
15
- function runConfigSetup() {
15
+ function runConfigSetup(options?: Parameters<typeof tina>[0]) {
16
16
  const addMiddleware = vi.fn();
17
17
  const updateConfig = vi.fn();
18
18
  const logger = { warn: vi.fn(), info: vi.fn() };
19
- const integration = tina();
19
+ const integration = tina(options);
20
20
  (
21
21
  integration.hooks['astro:config:setup'] as NonNullable<
22
22
  Hooks['astro:config:setup']
@@ -28,6 +28,34 @@ function runConfigSetup() {
28
28
  return { integration, addMiddleware, updateConfig, logger, plugins };
29
29
  }
30
30
 
31
+ // Drive the astro:config:done hook with a chosen adapter so the integration can
32
+ // resolve whether the Cloudflare workaround should apply.
33
+ function runConfigDone(integration: AstroIntegration, adapterName?: string) {
34
+ (
35
+ integration.hooks['astro:config:done'] as NonNullable<
36
+ Hooks['astro:config:done']
37
+ >
38
+ )({
39
+ config: {
40
+ build: { client: pathToFileURL('/tmp/tina-client/') },
41
+ adapter: adapterName ? { name: adapterName, hooks: {} } : undefined,
42
+ },
43
+ } as unknown as ConfigDoneArg);
44
+ }
45
+
46
+ const cfPlugin = (plugins: VitePlugin[]) =>
47
+ plugins.find(
48
+ (p) => p.name === '@tinacms/astro:cloudflare-import-meta-url'
49
+ ) as VitePlugin;
50
+
51
+ // Invoke a plugin's `configEnvironment` hook (a function in our plugin) without
52
+ // a real Vite environment — it only branches on the environment name.
53
+ function runConfigEnvironment(plugin: VitePlugin, name: string) {
54
+ const hook = plugin.configEnvironment as any;
55
+ const fn = typeof hook === 'function' ? hook : hook?.handler;
56
+ return fn?.call({}, name, {}, {});
57
+ }
58
+
31
59
  // Drive a Vite plugin's `configureServer` and capture the request handler it
32
60
  // registers, so we can exercise it without a real dev server.
33
61
  function devHandler(plugin: VitePlugin) {
@@ -96,6 +124,82 @@ describe('tina() integration — bridge dev plugin', () => {
96
124
  });
97
125
  });
98
126
 
127
+ describe('tina() integration — cloudflare import.meta.url plugin', () => {
128
+ const EXPECTED = JSON.stringify('file:///worker.mjs');
129
+
130
+ it('injects a valid import.meta.url define for the workerd server envs under the cloudflare adapter', () => {
131
+ const { integration, plugins } = runConfigSetup();
132
+ runConfigDone(integration, '@astrojs/cloudflare');
133
+ const plugin = cfPlugin(plugins);
134
+ expect(plugin).toBeDefined();
135
+
136
+ for (const env of ['ssr', 'astro']) {
137
+ const result = runConfigEnvironment(plugin, env);
138
+ expect(result?.define?.['import.meta.url']).toBe(EXPECTED);
139
+ // The placeholder must itself be a valid absolute URL.
140
+ expect(
141
+ () => new URL(JSON.parse(result.define['import.meta.url']))
142
+ ).not.toThrow();
143
+ }
144
+ });
145
+
146
+ it('never injects the define into the client or prerender envs', () => {
147
+ const { integration, plugins } = runConfigSetup();
148
+ runConfigDone(integration, '@astrojs/cloudflare');
149
+ const plugin = cfPlugin(plugins);
150
+ // `client` keeps the real import.meta.url; `prerender` may run in Node.
151
+ expect(runConfigEnvironment(plugin, 'client')).toBeUndefined();
152
+ expect(runConfigEnvironment(plugin, 'prerender')).toBeUndefined();
153
+ });
154
+
155
+ it('does not inject the define for non-cloudflare adapters', () => {
156
+ for (const adapter of ['@astrojs/node', '@astrojs/vercel']) {
157
+ const { integration, plugins } = runConfigSetup();
158
+ runConfigDone(integration, adapter);
159
+ const plugin = cfPlugin(plugins);
160
+ for (const env of ['ssr', 'prerender', 'astro', 'client']) {
161
+ expect(runConfigEnvironment(plugin, env)).toBeUndefined();
162
+ }
163
+ }
164
+ });
165
+
166
+ it('does not inject the define when no adapter is configured', () => {
167
+ const { integration, plugins } = runConfigSetup();
168
+ runConfigDone(integration);
169
+ expect(runConfigEnvironment(cfPlugin(plugins), 'ssr')).toBeUndefined();
170
+ });
171
+
172
+ it('reads the adapter flag lazily, after config:done', () => {
173
+ const { integration, plugins } = runConfigSetup();
174
+ const plugin = cfPlugin(plugins);
175
+ // Before config:done the flag is false, so the plugin is inert.
176
+ expect(runConfigEnvironment(plugin, 'ssr')).toBeUndefined();
177
+ // The same plugin instance picks up the adapter once config:done runs.
178
+ runConfigDone(integration, '@astrojs/cloudflare');
179
+ expect(
180
+ runConfigEnvironment(plugin, 'ssr')?.define?.['import.meta.url']
181
+ ).toBe(EXPECTED);
182
+ });
183
+
184
+ it('honours the cloudflareWorkers option override', () => {
185
+ // Force on, even with a Node adapter.
186
+ const forcedOn = runConfigSetup({ cloudflareWorkers: true });
187
+ runConfigDone(forcedOn.integration, '@astrojs/node');
188
+ expect(
189
+ runConfigEnvironment(cfPlugin(forcedOn.plugins), 'ssr')?.define?.[
190
+ 'import.meta.url'
191
+ ]
192
+ ).toBe(EXPECTED);
193
+
194
+ // Force off, even with the Cloudflare adapter.
195
+ const forcedOff = runConfigSetup({ cloudflareWorkers: false });
196
+ runConfigDone(forcedOff.integration, '@astrojs/cloudflare');
197
+ expect(
198
+ runConfigEnvironment(cfPlugin(forcedOff.plugins), 'ssr')
199
+ ).toBeUndefined();
200
+ });
201
+ });
202
+
99
203
  describe('tina() integration — astro:build:done', () => {
100
204
  it('emits bridge.js into the client output dir', () => {
101
205
  const clientDir = mkdtempSync(join(tmpdir(), 'tina-client-'));
@@ -7,11 +7,32 @@ import type { Plugin as VitePlugin } from 'vite';
7
7
 
8
8
  export interface TinaIntegrationOptions {
9
9
  middlewareOrder?: 'pre' | 'post';
10
+ /**
11
+ * Force the Cloudflare Workers (workerd) `import.meta.url` workaround on or
12
+ * off. When omitted, it is applied automatically whenever the
13
+ * `@astrojs/cloudflare` adapter is detected. Set `false` to opt out, or
14
+ * `true` to force it for a custom Cloudflare setup.
15
+ */
16
+ cloudflareWorkers?: boolean;
10
17
  }
11
18
 
12
19
  /** Where the injected bridge bootstrap imports the bridge bundle from. */
13
20
  const BRIDGE_ROUTE = '/admin/bridge.js';
14
21
 
22
+ const CLOUDFLARE_ADAPTER_NAME = '@astrojs/cloudflare';
23
+ /** A valid absolute URL that stands in for `import.meta.url` in server bundles. */
24
+ const WORKERD_IMPORT_META_URL = JSON.stringify('file:///worker.mjs');
25
+ /**
26
+ * The Vite environments that run on workerd under `@astrojs/cloudflare` and
27
+ * therefore need the placeholder: `ssr` (the on-demand server build) and `astro`
28
+ * (the dev SSR runtime). The `client` bundle is excluded so the browser keeps
29
+ * the real `import.meta.url`, and `prerender` is excluded because it runs in
30
+ * real Node when `prerenderEnvironment: 'node'` (faking the URL there would
31
+ * break genuine file resolution) — and the island route is `prerender: false`,
32
+ * so it never renders during prerendering anyway.
33
+ */
34
+ const SERVER_ENVIRONMENTS = new Set(['ssr', 'astro']);
35
+
15
36
  export default function tina(
16
37
  options: TinaIntegrationOptions = {}
17
38
  ): AstroIntegration {
@@ -19,6 +40,10 @@ export default function tina(
19
40
  // Resolved client output dir, captured at config:done and consumed at
20
41
  // build:done (only then is the final output location known).
21
42
  let clientDir: URL | undefined;
43
+ // Whether the Cloudflare adapter is in use, resolved at config:done. The
44
+ // injected Vite plugin reads it lazily (via a thunk) because the plugin is
45
+ // constructed at config:setup, before the adapter is known.
46
+ let isCloudflareAdapter = false;
22
47
 
23
48
  return {
24
49
  name: '@tinacms/astro',
@@ -32,10 +57,23 @@ export default function tina(
32
57
  // it into the user's source tree — config-time writes churn
33
58
  // public/admin on every `astro dev`/`astro build`, break on read-only
34
59
  // or sandboxed filesystems, and can race `tinacms build`.
35
- updateConfig({ vite: { plugins: [bridgeDevPlugin()] } });
60
+ updateConfig({
61
+ vite: {
62
+ plugins: [
63
+ bridgeDevPlugin(),
64
+ cloudflareImportMetaUrlPlugin(() => isCloudflareAdapter),
65
+ ],
66
+ },
67
+ });
36
68
  },
37
69
  'astro:config:done': ({ config }) => {
38
70
  clientDir = config.build.client;
71
+ // config:done runs before Vite is created and after every integration
72
+ // (including a late-registered adapter) has resolved, so config.adapter
73
+ // is reliable here.
74
+ isCloudflareAdapter =
75
+ options.cloudflareWorkers ??
76
+ config.adapter?.name === CLOUDFLARE_ADAPTER_NAME;
39
77
  },
40
78
  'astro:build:done': ({ logger }) => {
41
79
  // Build: emit the bridge next to the admin SPA in the *output* tree.
@@ -82,6 +120,36 @@ function bridgeDevPlugin(): VitePlugin {
82
120
  };
83
121
  }
84
122
 
123
+ /**
124
+ * Cloudflare Workers (workerd) runs the server bundle in a runtime where the
125
+ * bundled `import.meta.url` is not a valid absolute URL. Astro's experimental
126
+ * Container API — used by the on-demand island route — calls
127
+ * `new URL(import.meta.url)` while building its manifest, which throws
128
+ * "Invalid URL string" and 500s the island render in production. The paths it
129
+ * seeds from that URL are never dereferenced for an in-memory render, so a
130
+ * valid placeholder is harmless. We inject it as a Vite `define`, but only in
131
+ * the server environments (never the client bundle, which needs the real value)
132
+ * and only under the Cloudflare adapter (faking it on a Node server would break
133
+ * real file resolution).
134
+ *
135
+ * TODO: remove once Astro guards the unconditional `new URL(import.meta.url)` in
136
+ * createManifest (tracked upstream in withastro/astro).
137
+ */
138
+ function cloudflareImportMetaUrlPlugin(
139
+ isCloudflare: () => boolean
140
+ ): VitePlugin {
141
+ const define = { 'import.meta.url': WORKERD_IMPORT_META_URL };
142
+ return {
143
+ name: '@tinacms/astro:cloudflare-import-meta-url',
144
+ // No `apply`: the define is needed in both the build (ssr) and dev envs.
145
+ configEnvironment(name) {
146
+ if (!isCloudflare()) return;
147
+ if (!SERVER_ENVIRONMENTS.has(name)) return;
148
+ return { define };
149
+ },
150
+ };
151
+ }
152
+
85
153
  function emitBridgeAsset(adminDir: string, logger: AstroIntegrationLogger) {
86
154
  try {
87
155
  mkdirSync(adminDir, { recursive: true });