diff --git a/.changeset/fix-generated-client-types-import.md b/.changeset/fix-generated-client-types-import.md new file mode 100644 index 0000000000..3f8d3de563 --- /dev/null +++ b/.changeset/fix-generated-client-types-import.md @@ -0,0 +1,5 @@ +--- +"@tinacms/cli": patch +--- + +Fix the generated `client.ts` / `databaseClient.ts` `./types` import so it satisfies both TypeScript strict mode and Node native ESM. The generated import is now `import { queries } from "./types.js"` unconditionally, and the CLI emits a co-resident `types.js` alongside `types.ts` for TypeScript projects. Modern TS module resolution (`bundler` / `node16` / `nodenext`) rewrites the `.js` import back to `types.ts` at compile time, so type checking still sees the `.ts` source and `allowImportingTsExtensions` is not required, while Node ESM consumers resolve the on-disk `.js` file at runtime. diff --git a/.changeset/fresh-tomatoes-look.md b/.changeset/fresh-tomatoes-look.md new file mode 100644 index 0000000000..4eb57a9021 --- /dev/null +++ b/.changeset/fresh-tomatoes-look.md @@ -0,0 +1,5 @@ +--- +"@tinacms/graphql": patch +--- + +fix(graphql): preserve absolute external URLs in image-type resolvers diff --git a/packages/@tinacms/cli/src/next/codegen/__fixtures__/generated-consumer/client.ts b/packages/@tinacms/cli/src/next/codegen/__fixtures__/generated-consumer/client.ts new file mode 100644 index 0000000000..60b51c041f --- /dev/null +++ b/packages/@tinacms/cli/src/next/codegen/__fixtures__/generated-consumer/client.ts @@ -0,0 +1,6 @@ +import { queries } from './types.js'; + +export const client = { + queries: queries({ request: (q: string) => null }), +}; +export default client; diff --git a/packages/@tinacms/cli/src/next/codegen/__fixtures__/generated-consumer/tsconfig.json b/packages/@tinacms/cli/src/next/codegen/__fixtures__/generated-consumer/tsconfig.json new file mode 100644 index 0000000000..4c19240e09 --- /dev/null +++ b/packages/@tinacms/cli/src/next/codegen/__fixtures__/generated-consumer/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true + }, + "include": ["**/*.ts"] +} diff --git a/packages/@tinacms/cli/src/next/codegen/__fixtures__/generated-consumer/tsconfig.node.json b/packages/@tinacms/cli/src/next/codegen/__fixtures__/generated-consumer/tsconfig.node.json new file mode 100644 index 0000000000..749033d498 --- /dev/null +++ b/packages/@tinacms/cli/src/next/codegen/__fixtures__/generated-consumer/tsconfig.node.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "esnext", + "moduleResolution": "node", + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true + }, + "include": ["**/*.ts"] +} diff --git a/packages/@tinacms/cli/src/next/codegen/__fixtures__/generated-consumer/types.ts b/packages/@tinacms/cli/src/next/codegen/__fixtures__/generated-consumer/types.ts new file mode 100644 index 0000000000..397ba2ab30 --- /dev/null +++ b/packages/@tinacms/cli/src/next/codegen/__fixtures__/generated-consumer/types.ts @@ -0,0 +1,3 @@ +export const queries = (_client: { request: (q: string) => unknown }) => ({ + hello: (): string => 'hello', +}); diff --git a/packages/@tinacms/cli/src/next/codegen/consumer-resolution.test.ts b/packages/@tinacms/cli/src/next/codegen/consumer-resolution.test.ts new file mode 100644 index 0000000000..ab29435208 --- /dev/null +++ b/packages/@tinacms/cli/src/next/codegen/consumer-resolution.test.ts @@ -0,0 +1,108 @@ +// Consumer-side regression test for the generated `./types.js` import in +// `client.ts` / `databaseClient.ts`. The unit tests in index.test.ts only +// string-match the generator output; this suite actually runs the two +// real consumers (the TypeScript compiler and Node native ESM) against a +// fixture that mirrors what the generator emits. +// +// This is the test #6739 needed: it spawns `tsc --noEmit` over a fixture +// with a Next.js-style tsconfig (moduleResolution: bundler, no +// allowImportingTsExtensions) and asserts the import resolves cleanly. +// It also esbuild-transforms the fixture to JS and asserts Node ESM can +// load it — the failure mode that originally produced #6062. + +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { transform } from 'esbuild'; + +const FIXTURE_DIR = path.join(__dirname, '__fixtures__', 'generated-consumer'); + +function runTsc(tsconfigFile: string) { + const tscPath = require.resolve('typescript/bin/tsc'); + return spawnSync( + process.execPath, + [tscPath, '--noEmit', '-p', path.join(FIXTURE_DIR, tsconfigFile)], + { encoding: 'utf-8' } + ); +} + +describe('generated client consumer resolution', () => { + // The issue table lists three import-string forms that real-world consumers + // require: `./types` (TS default), `./types.ts` (allowImportingTsExtensions), + // and `./types.js` (Node ESM). The CLI emits the `.js` form unconditionally; + // these tests cover the two TypeScript moduleResolution modes our users hit + // (`bundler` and legacy `node`) and the Node ESM runtime. + it('tsc resolves "./types.js" under moduleResolution: bundler (Next.js 13.2+ default)', () => { + const result = runTsc('tsconfig.json'); + if (result.status !== 0) { + // eslint-disable-next-line no-console + console.error('tsc stdout:', result.stdout); + // eslint-disable-next-line no-console + console.error('tsc stderr:', result.stderr); + } + expect(result.status).toBe(0); + }); + + it('tsc resolves "./types.js" under moduleResolution: node (legacy) with strict on', () => { + // Pins the coverage gap the user surfaced: even classic node resolution + // (no `.js` → `.ts` rewrite) must be satisfied. It is, because we ship a + // co-resident `types.ts` next to `types.js` — TypeScript 4.7+ finds the + // sibling .ts source for the .js import. If a future change ever drops + // `types.ts` from the TS branch, strict-mode users on legacy resolution + // would regress; this test catches that. + const result = runTsc('tsconfig.node.json'); + if (result.status !== 0) { + // eslint-disable-next-line no-console + console.error('tsc stdout:', result.stdout); + // eslint-disable-next-line no-console + console.error('tsc stderr:', result.stderr); + } + expect(result.status).toBe(0); + }); + + it('node native ESM resolves the generated client when types.js is co-resident on disk', async () => { + // Mirrors what the CLI now produces for TS projects: types.ts plus a + // co-resident types.js. We only need the .js files for the runtime + // check, so transform the TS sources straight into the temp dir. + const tmp = mkdtempSync(path.join(tmpdir(), 'tina-types-js-')); + try { + const clientTs = readFileSync( + path.join(FIXTURE_DIR, 'client.ts'), + 'utf-8' + ); + const typesTs = readFileSync(path.join(FIXTURE_DIR, 'types.ts'), 'utf-8'); + + const clientJs = await transform(clientTs, { loader: 'ts' }); + const typesJs = await transform(typesTs, { loader: 'ts' }); + + writeFileSync(path.join(tmp, 'client.js'), clientJs.code); + writeFileSync(path.join(tmp, 'types.js'), typesJs.code); + // Force ESM semantics for the .js files in this directory. + writeFileSync( + path.join(tmp, 'package.json'), + JSON.stringify({ type: 'module' }) + ); + writeFileSync( + path.join(tmp, 'entry.mjs'), + "import('./client.js').then(() => process.exit(0)).catch(err => { console.error(err); process.exit(1); });\n" + ); + + const result = spawnSync( + process.execPath, + [path.join(tmp, 'entry.mjs')], + { encoding: 'utf-8' } + ); + + if (result.status !== 0) { + // eslint-disable-next-line no-console + console.error('node stdout:', result.stdout); + // eslint-disable-next-line no-console + console.error('node stderr:', result.stderr); + } + expect(result.status).toBe(0); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/@tinacms/cli/src/next/codegen/index.test.ts b/packages/@tinacms/cli/src/next/codegen/index.test.ts index 62ea160a30..f9ca464231 100644 --- a/packages/@tinacms/cli/src/next/codegen/index.test.ts +++ b/packages/@tinacms/cli/src/next/codegen/index.test.ts @@ -2,6 +2,7 @@ jest.mock('fs-extra', () => ({ ensureFile: jest.fn().mockResolvedValue(undefined), outputFile: jest.fn().mockResolvedValue(undefined), existsSync: jest.fn().mockReturnValue(false), + unlinkSync: jest.fn(), stat: jest.fn().mockResolvedValue({ size: 0 }), })); jest.mock( @@ -34,15 +35,15 @@ describe('Codegen.genClient', () => { return instance; } - it('emits extensionless ./types import for TypeScript projects', async () => { - // Avoids requiring `allowImportingTsExtensions: true` in consumer - // tsconfigs. Modern TS module resolution (Bundler / NodeNext) resolves - // `./types` to `./types.ts` cleanly. The previous `./types.ts` import - // broke under stricter Next.js / TS defaults (see #6062 follow-up). + it('emits ./types.js import for TypeScript projects', async () => { + // The .js extension satisfies Node native ESM at runtime. Modern TS + // module resolution (bundler / node16 / nodenext) rewrites `./types.js` + // back to `./types.ts` at compile time, so type checking still sees + // the .ts source and `allowImportingTsExtensions` is not required. const { clientString } = await makeInstance(true).genClient(); - expect(clientString).toContain('from "./types"'); + expect(clientString).toContain('from "./types.js"'); expect(clientString).not.toMatch(/from ["']\.\/types\.ts["']/); - expect(clientString).not.toMatch(/from ["']\.\/types\.js["']/); + expect(clientString).not.toMatch(/from ["']\.\/types["']/); }); it('emits ./types.js import for non-TypeScript projects', async () => { @@ -65,11 +66,11 @@ describe('Codegen.genDatabaseClient', () => { return instance; } - it('emits extensionless ./types import for TypeScript projects', async () => { + it('emits ./types.js import for TypeScript projects', async () => { const result = await makeInstance(true).genDatabaseClient(); - expect(result).toContain('from "./types"'); + expect(result).toContain('from "./types.js"'); expect(result).not.toMatch(/from ["']\.\/types\.ts["']/); - expect(result).not.toMatch(/from ["']\.\/types\.js["']/); + expect(result).not.toMatch(/from ["']\.\/types["']/); }); it('emits ./types.js import for non-TypeScript projects', async () => { @@ -197,6 +198,66 @@ describe('Codegen.execute integration', () => { } }); + it('emits types.js alongside types.ts in TS projects and does not unlink it', async () => { + // Regression guard for #6829. Before this fix, the TS branch wrote + // types.ts and then `unlinkIfExists(generatedTypesJSFilePath)`, leaving + // Node native ESM users (#6062) with no `./types.js` to resolve. The + // generated client now imports `"./types.js"` unconditionally, so + // `types.js` must be co-resident with `types.ts` on disk in TS mode. + const fs = jest.requireMock('fs-extra'); + const esbuild = jest.requireMock('esbuild'); + (fs.outputFile as jest.Mock).mockClear(); + (fs.unlinkSync as jest.Mock).mockClear(); + (fs.existsSync as jest.Mock).mockReturnValue(true); + (esbuild.transform as jest.Mock).mockClear(); + (esbuild.transform as jest.Mock).mockResolvedValue({ code: '/*js*/' }); + + const genTypesSpy = jest + .spyOn(Codegen.prototype, 'genTypes') + .mockResolvedValue({ + codeString: '/*ts source*/', + schemaString: '/*gql schema*/', + }); + + try { + const codegen = stubCodegen(); + const cm = codegen.configManager as any; + cm.shouldSkipSDK = () => false; + cm.isUsingTs = () => true; + cm.hasSelfHostedConfig = () => false; + cm.generatedGraphQLGQLPath = '/fake/tina/__generated__/schema.gql'; + cm.generatedTypesTSFilePath = '/fake/tina/__generated__/types.ts'; + cm.generatedTypesJSFilePath = '/fake/tina/__generated__/types.js'; + cm.generatedTypesDFilePath = '/fake/tina/__generated__/types.d.ts'; + cm.generatedClientTSFilePath = '/fake/tina/__generated__/client.ts'; + cm.generatedClientJSFilePath = '/fake/tina/__generated__/client.js'; + cm.generatedCachePath = '/fake/cache'; + + await codegen.execute(); + + const writtenPaths = (fs.outputFile as jest.Mock).mock.calls.map( + (c: any[]) => c[0] + ); + expect(writtenPaths).toContain(cm.generatedTypesTSFilePath); + expect(writtenPaths).toContain(cm.generatedTypesJSFilePath); + expect(writtenPaths).toContain(cm.generatedClientTSFilePath); + + expect(esbuild.transform).toHaveBeenCalledWith('/*ts source*/', { + loader: 'ts', + }); + + const unlinkedPaths = (fs.unlinkSync as jest.Mock).mock.calls.map( + (c: any[]) => c[0] + ); + expect(unlinkedPaths).not.toContain(cm.generatedTypesJSFilePath); + expect(unlinkedPaths).toContain(cm.generatedClientJSFilePath); + expect(unlinkedPaths).toContain(cm.generatedTypesDFilePath); + } finally { + genTypesSpy.mockRestore(); + (fs.existsSync as jest.Mock).mockReturnValue(false); + } + }); + it('still writes exactly once when the project has a separate content root', async () => { // Strengthens the previous test: even when hasSeparateContentRoot returns // true (the multi-repo flag the duplicate-write block used to gate on), diff --git a/packages/@tinacms/cli/src/next/codegen/index.ts b/packages/@tinacms/cli/src/next/codegen/index.ts index e239e3c104..4ae31cd019 100644 --- a/packages/@tinacms/cli/src/next/codegen/index.ts +++ b/packages/@tinacms/cli/src/next/codegen/index.ts @@ -147,6 +147,15 @@ export class Codegen { this.configManager.generatedTypesTSFilePath, codeString ); + // Co-resident types.js so the generated `import { queries } from "./types.js"` + // works under Node native ESM at runtime. Modern TS module resolution + // (bundler / node16 / nodenext) rewrites the .js import to types.ts at + // compile time, so type checking still sees the .ts source. + const jsTypes = await transform(codeString, { loader: 'ts' }); + await fs.outputFile( + this.configManager.generatedTypesJSFilePath, + jsTypes.code + ); await fs.outputFile( this.configManager.generatedClientTSFilePath, clientString @@ -159,7 +168,6 @@ export class Codegen { } await unlinkIfExists(this.configManager.generatedClientJSFilePath); await unlinkIfExists(this.configManager.generatedTypesDFilePath); - await unlinkIfExists(this.configManager.generatedTypesJSFilePath); } else { // Write out the generated types. // write types.js and types.d.ts @@ -289,7 +297,7 @@ export class Codegen { import { resolve } from "@tinacms/datalayer"; import type { TinaClient } from "tinacms/dist/client"; -import { queries } from "${this.configManager.isUsingTs() ? './types' : './types.js'}"; +import { queries } from "./types.js"; import database from "../database"; export async function databaseRequest({ query, variables, user }) { @@ -357,7 +365,7 @@ export default databaseClient; const apiURL = this.getApiURL(); const clientString = `import { createClient } from "tinacms/dist/client"; -import { queries } from "${this.configManager.isUsingTs() ? './types' : './types.js'}"; +import { queries } from "./types.js"; export const client = createClient({ ${ this.noClientBuildCache === false ? `cacheDir: '${normalizePath( diff --git a/packages/@tinacms/cli/tsconfig.json b/packages/@tinacms/cli/tsconfig.json index 17e7eede74..40a0572155 100644 --- a/packages/@tinacms/cli/tsconfig.json +++ b/packages/@tinacms/cli/tsconfig.json @@ -10,7 +10,8 @@ "src/**/*.test.tsx", "src/**/*.test.ts", "src/**/*.spec.ts", - "src/**/*.spec.tsx" + "src/**/*.spec.tsx", + "src/**/__fixtures__/**" ], "include": [ "src" diff --git a/packages/@tinacms/graphql/src/resolver/media-rich-text.test.ts b/packages/@tinacms/graphql/src/resolver/media-rich-text.test.ts new file mode 100644 index 0000000000..5a7f2ca5d2 --- /dev/null +++ b/packages/@tinacms/graphql/src/resolver/media-rich-text.test.ts @@ -0,0 +1,174 @@ +/** + * End-to-end round-trip through `parseMDX` + `serializeMDX` for a rich-text + * field that contains a JSX template with an `image`-typed src prop. Exercises + * the same pipeline the GraphQL `rich-text` resolver uses, with the actual + * resolver callbacks plugged in. + * + * Guards against regressing the absolute-URL guard in `media-utils.ts`: an + * `image`-typed src that is already an external URL must survive both the + * relative→cloud (read) and cloud→relative (save) transforms unchanged, while + * a media-library path must still pick up the staging branch prefix on read + * and round-trip back to its original form on save. + */ + +import { parseMDX, serializeMDX } from '@tinacms/mdx'; +import type { RichTextType, Schema } from '@tinacms/schema-tools'; +import { describe, expect, it } from 'vitest'; +import type { GraphQLConfig } from '../types'; +import { + resolveMediaCloudToRelative, + resolveMediaRelativeToCloud, +} from './media-utils'; + +const bodyField: RichTextType = { + type: 'rich-text', + name: 'body', + templates: [ + { + name: 'imageEmbed', + label: 'Image', + fields: [ + { name: 'alt', label: 'Alt', type: 'string' }, + { name: 'src', label: 'Src', type: 'image', required: true }, + ], + }, + ], +}; + +const schema: Schema = { + config: { + branch: '', + clientId: '', + token: '', + build: { outputFolder: '', publicFolder: '' }, + schema: { collections: [] }, + media: { + tina: { + publicFolder: 'public', + mediaRoot: 'uploads', + }, + }, + }, + collections: [], +}; + +// Feature branch with media rooted at `main`: staging prefix is active. +const config: GraphQLConfig = { + useRelativeMedia: false, + assetsHost: 'assets.tinajs.dev', + clientId: 'a03ff3e2-1c3a-41af-8afd-ba0d58853191', + branch: 'feat/example', + mediaBranch: 'main', +}; + +const readCallback = (url: string) => + resolveMediaRelativeToCloud(url, config, schema) as string; +const writeCallback = (url: string) => + resolveMediaCloudToRelative(url, config, schema) as string; + +type ImageEmbed = { src: unknown; alt: unknown }; + +const findImageEmbeds = (tree: ReturnType): ImageEmbed[] => { + const embeds: ImageEmbed[] = []; + for (const child of tree.children ?? []) { + if ( + child && + typeof child === 'object' && + 'name' in child && + child.name === 'imageEmbed' && + 'props' in child + ) { + const props = (child as { props: Record }).props; + embeds.push({ src: props.src, alt: props.alt }); + } + } + return embeds; +}; + +describe('media-resolver round-trip through rich-text JSX templates', () => { + it('round-trips an absolute external URL identically while still staging local media', () => { + const relativeSrc = '/uploads/library/local.png'; + const externalSrc = 'https://cdn.example.com/external/image.png'; + + const inputMdx = [ + ``, + '', + ``, + ].join('\n'); + + // READ: server resolves stored MDX into the editor tree. + const editorTree = parseMDX(inputMdx, bodyField, readCallback); + const [local, external] = findImageEmbeds(editorTree); + + // Local media gets the cloud-assets + staging-branch prefix, with the + // media-root segment stripped (S3 key is rooted at the file path). + expect(local.src).toBe( + `https://${config.assetsHost}/${config.clientId}/__staging/${config.branch}/__file/library/local.png` + ); + + // Absolute external URL passes through untouched in the read direction. + expect(external.src).toBe(externalSrc); + + // WRITE: editor sends the tree back, server serializes to MDX. + const serialized = serializeMDX(editorTree, bodyField, writeCallback); + if (typeof serialized !== 'string') { + throw new Error('Expected serializeMDX to return a string'); + } + + // Round-trip identity: the serialized output equals the input. + expect(serialized.trim()).toBe(inputMdx.trim()); + + // Re-parsing the serialized output yields the same editor-side values. + const reparsed = parseMDX(serialized, bodyField, readCallback); + const [localAgain, externalAgain] = findImageEmbeds(reparsed); + expect(localAgain.src).toBe(local.src); + expect(externalAgain.src).toBe(externalSrc); + }); + + it('round-trips protocol-relative and http URLs identically', () => { + const protocolRelativeSrc = '//cdn.example.com/img.png'; + const httpSrc = 'http://example.com/img.jpg'; + + const inputMdx = [ + ``, + '', + ``, + ].join('\n'); + + const editorTree = parseMDX(inputMdx, bodyField, readCallback); + const [protoRel, http] = findImageEmbeds(editorTree); + + expect(protoRel.src).toBe(protocolRelativeSrc); + expect(http.src).toBe(httpSrc); + + const serialized = serializeMDX(editorTree, bodyField, writeCallback); + if (typeof serialized !== 'string') { + throw new Error('Expected serializeMDX to return a string'); + } + expect(serialized.trim()).toBe(inputMdx.trim()); + }); + + it('self-heals a corrupted src on read and writes it back cleanly on save', () => { + // Shape of a previously-corrupted value: the configured media root + // (`/uploads`) was concatenated directly in front of an absolute URL by + // an earlier broken round-trip. After this round-trip the disk value + // should be back to the clean external URL. + const corruptedSrc = + '/uploadshttps://github.com/owner/repo/assets/123/abc.png'; + const healedSrc = 'https://github.com/owner/repo/assets/123/abc.png'; + + const inputMdx = ``; + + const editorTree = parseMDX(inputMdx, bodyField, readCallback); + const [embed] = findImageEmbeds(editorTree); + expect(embed.src).toBe(healedSrc); + + const serialized = serializeMDX(editorTree, bodyField, writeCallback); + if (typeof serialized !== 'string') { + throw new Error('Expected serializeMDX to return a string'); + } + expect(serialized.trim()).toBe( + `` + ); + }); +}); diff --git a/packages/@tinacms/graphql/src/resolver/media-utils.test.ts b/packages/@tinacms/graphql/src/resolver/media-utils.test.ts index b3e3c43e05..0c6cd068a7 100644 --- a/packages/@tinacms/graphql/src/resolver/media-utils.test.ts +++ b/packages/@tinacms/graphql/src/resolver/media-utils.test.ts @@ -311,6 +311,135 @@ describe('resolveMedia', () => { expect(resolvedURL).toEqual(otherClientURL); }); + /** + * Absolute URLs of any scheme — http(s), data, blob, file, mailto — and + * protocol-relative URLs point outside the user's media root. They must + * round-trip identically in both directions on every branch, so that + * content authored with hard-coded external links (or inline base64 + * payloads) is preserved on read and on save. + */ + it.each([ + 'https://github.com/owner/repo/assets/123/abc.png', + 'http://example.com/img.jpg', + '//cdn.example.com/img.jpg', + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', + 'blob:https://example.com/abcdef12-3456-7890-abcd-ef1234567890', + 'file:///tmp/img.png', + ])('leaves absolute URL %s untouched on both directions', (url) => { + const config: GraphQLConfig = { + useRelativeMedia: false, + assetsHost, + clientId, + branch: 'feat/x', + mediaBranch: 'main', + }; + + expect(resolveMediaRelativeToCloud(url, config, schema)).toEqual(url); + expect(resolveMediaCloudToRelative(url, config, schema)).toEqual(url); + }); + + /** + * Self-heal: a previously-corrupted document on disk can store values like + * `/uploadshttps://…` where the media root was concatenated directly in + * front of an absolute URL. On read, after stripping the media root the + * remaining value is itself an absolute URL; return that directly rather + * than re-wrapping it with the cloud assets prefix. On the next save the + * value lands back on disk in its clean form. + */ + it.each([ + { + stored: '/uploadshttps://github.com/owner/repo/assets/123/abc.png', + healed: 'https://github.com/owner/repo/assets/123/abc.png', + }, + { + stored: '/uploadshttp://example.com/img.jpg', + healed: 'http://example.com/img.jpg', + }, + { + stored: '/uploads//cdn.example.com/img.jpg', + healed: '//cdn.example.com/img.jpg', + }, + { + stored: '/uploadsdata:image/png;base64,AAAA', + healed: 'data:image/png;base64,AAAA', + }, + ])( + 'self-heals previously-corrupted value $stored to $healed on read', + ({ stored, healed }) => { + const config: GraphQLConfig = { + useRelativeMedia: false, + assetsHost, + clientId, + branch: 'feat/x', + mediaBranch: 'main', + }; + + expect(resolveMediaRelativeToCloud(stored, config, schema)).toEqual( + healed + ); + } + ); + + /** + * Self-heal also applies inside array values — mixed clean + corrupted + * entries are normalized into their intended form alongside relative + * entries still picking up the staging prefix. + */ + it('self-heals corrupted entries inside mixed array values', () => { + const config: GraphQLConfig = { + useRelativeMedia: false, + assetsHost, + clientId, + branch: 'feat/x', + mediaBranch: 'main', + }; + + const resolved = resolveMediaRelativeToCloud( + [ + '/uploads/a.png', + '/uploadshttps://github.com/owner/repo/assets/123/abc.png', + '/uploads/b.png', + ], + config, + schema + ); + expect(resolved).toEqual([ + `https://${assetsHost}/${clientId}/__staging/feat/x/__file/a.png`, + 'https://github.com/owner/repo/assets/123/abc.png', + `https://${assetsHost}/${clientId}/__staging/feat/x/__file/b.png`, + ]); + }); + + /** + * Array values containing a mix of relative media paths and absolute + * external URLs must rewrite only the relative entries and preserve the + * external ones verbatim. + */ + it('preserves absolute external URLs inside mixed array values', () => { + const config: GraphQLConfig = { + useRelativeMedia: false, + assetsHost, + clientId, + branch: 'feat/x', + mediaBranch: 'main', + }; + + const resolved = resolveMediaRelativeToCloud( + [ + '/uploads/a.png', + 'https://github.com/owner/repo/assets/123/abc.png', + '/uploads/b.png', + ], + config, + schema + ); + expect(resolved).toEqual([ + `https://${assetsHost}/${clientId}/__staging/feat/x/__file/a.png`, + 'https://github.com/owner/repo/assets/123/abc.png', + `https://${assetsHost}/${clientId}/__staging/feat/x/__file/b.png`, + ]); + }); + /** * Missing `media: { tina: { ... }}` config should return the value, regardless of `useRelativeMedia` */ diff --git a/packages/@tinacms/graphql/src/resolver/media-utils.ts b/packages/@tinacms/graphql/src/resolver/media-utils.ts index 7284d23ec4..4b7363ae5f 100644 --- a/packages/@tinacms/graphql/src/resolver/media-utils.ts +++ b/packages/@tinacms/graphql/src/resolver/media-utils.ts @@ -74,13 +74,23 @@ export const resolveMediaRelativeToCloud = ( const cleanMediaRoot = cleanUpSlashes(schema.config.media.tina.mediaRoot); const prefix = stagingPrefix(config); if (typeof value === 'string') { + // Absolute URLs (any scheme, or protocol-relative `//`) are not + // media-library paths — they live outside the configured media + // root and must not be wrapped with the cloud assets prefix. + if (ABSOLUTE_URL.test(value)) return value; const strippedValue = value.replace(cleanMediaRoot, ''); + // Self-heal: if stripping the media root reveals an absolute URL + // underneath (e.g. `/uploadshttps://…` from a previously corrupted + // round-trip), return that URL directly rather than re-wrapping it. + if (ABSOLUTE_URL.test(strippedValue)) return strippedValue; return `https://${config.assetsHost}/${config.clientId}${prefix}${strippedValue}`; } if (Array.isArray(value)) { return value.map((v) => { if (!v || typeof v !== 'string') return v; + if (ABSOLUTE_URL.test(v)) return v; const strippedValue = v.replace(cleanMediaRoot, ''); + if (ABSOLUTE_URL.test(strippedValue)) return strippedValue; return `https://${config.assetsHost}/${config.clientId}${prefix}${strippedValue}`; }); } @@ -117,6 +127,14 @@ const stripStagingPrefix = (path: string): string => { return match ? match[1] : path; }; +// Matches values that aren't media-library paths and must not be rewritten as +// branch-staged cloud URLs: +// - Any URL with a scheme — `https://`, `http://`, `data:`, `blob:`, +// `file:`, `mailto:`, etc. Scheme grammar per RFC 3986: +// ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ). +// - Protocol-relative URLs starting with `//`. +const ABSOLUTE_URL = /^[a-z][a-z0-9+.\-]*:|^\/\//i; + const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');