diff --git a/.babelrc b/.babelrc index f755ae2..b300bb0 100644 --- a/.babelrc +++ b/.babelrc @@ -23,9 +23,12 @@ ], "@babel/preset-react" ], - "plugins": [["@babel/plugin-transform-runtime", { - "regenerator": true - }]] + "plugins": [ + "./scripts/babel-plugin-fully-specified-relative-imports.cjs", + ["@babel/plugin-transform-runtime", { + "regenerator": true + }] + ] } } -} \ No newline at end of file +} diff --git a/scripts/babel-plugin-fully-specified-relative-imports.cjs b/scripts/babel-plugin-fully-specified-relative-imports.cjs new file mode 100644 index 0000000..134a4d5 --- /dev/null +++ b/scripts/babel-plugin-fully-specified-relative-imports.cjs @@ -0,0 +1,41 @@ +module.exports = function fullySpecifiedRelativeImports() { + function isRelative(specifier) { + return specifier.startsWith("./") || specifier.startsWith("../"); + } + + function hasKnownExtension(specifier) { + return /\.[a-zA-Z0-9]+$/.test(specifier); + } + + function fullySpecify(specifier) { + if (!isRelative(specifier)) return specifier; + if (hasKnownExtension(specifier)) return specifier; + return `${specifier}.js`; + } + + function rewriteSource(sourceNode) { + if (!sourceNode || typeof sourceNode.value !== "string") return; + sourceNode.value = fullySpecify(sourceNode.value); + } + + return { + name: "fully-specified-relative-imports", + visitor: { + ImportDeclaration(path) { + rewriteSource(path.node.source); + }, + ExportNamedDeclaration(path) { + rewriteSource(path.node.source); + }, + ExportAllDeclaration(path) { + rewriteSource(path.node.source); + }, + CallExpression(path) { + if (path.node.callee.type !== "Import") return; + const [firstArg] = path.node.arguments; + if (!firstArg || firstArg.type !== "StringLiteral") return; + firstArg.value = fullySpecify(firstArg.value); + }, + }, + }; +}; diff --git a/src/hooks/Provider.jsx b/src/hooks/Provider.jsx index 9c74f66..ef6eb8f 100644 --- a/src/hooks/Provider.jsx +++ b/src/hooks/Provider.jsx @@ -52,7 +52,7 @@ export const HCaptchaProvider = ({ }); setToken(response); - + return response; } catch (error) { setError(error); @@ -92,7 +92,7 @@ export const HCaptchaProvider = ({ onExpire={handleExpire} onError={handleError} ref={hcaptchaRef} - > + /> ); }; diff --git a/src/hooks/useHCaptcha.tsx b/src/hooks/useHCaptcha.jsx similarity index 100% rename from src/hooks/useHCaptcha.tsx rename to src/hooks/useHCaptcha.jsx diff --git a/tests/esm-extensions.spec.js b/tests/esm-extensions.spec.js new file mode 100644 index 0000000..55ac722 --- /dev/null +++ b/tests/esm-extensions.spec.js @@ -0,0 +1,149 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { spawnSync } from "child_process"; +import { afterAll, describe, expect, it } from "@jest/globals"; + +const PROJECT_ROOT = path.resolve(__dirname, ".."); +const DEFAULT_ESM_DIST_DIR = path.join(PROJECT_ROOT, "dist", "esm"); +let tempDirToCleanup = null; + +function buildEsmToTempDir() { + const tmpRoot = fs.mkdtempSync( + path.join(PROJECT_ROOT, ".tmp-react-hcaptcha-esm-") + ); + const outDir = path.join(tmpRoot, "esm"); + tempDirToCleanup = tmpRoot; + + const babelCliPath = require.resolve("@babel/cli/bin/babel.js"); + const result = spawnSync( + process.execPath, + [babelCliPath, "src", "-d", outDir, "--copy-files"], + { + cwd: PROJECT_ROOT, + encoding: "utf8", + env: { + ...process.env, + BABEL_ENV: "esm", + }, + } + ); + + if (result.status !== 0) { + throw new Error( + `Failed to build ESM via Babel.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}` + ); + } + + fs.writeFileSync( + path.join(outDir, "package.json"), + JSON.stringify({ type: "module" }), + "utf8" + ); + + return outDir; +} + +function getEsmDistDir() { + if ( + fs.existsSync(DEFAULT_ESM_DIST_DIR) && + fs.existsSync(path.join(DEFAULT_ESM_DIST_DIR, "index.js")) + ) { + return DEFAULT_ESM_DIST_DIR; + } + + return buildEsmToTempDir(); +} + +function listFilesRecursive(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...listFilesRecursive(fullPath)); + } else { + files.push(fullPath); + } + } + + return files; +} + +function getRelativeSpecifiers(sourceText) { + const specifiers = []; + + const patterns = [ + /\bimport\s+[^'"]*\sfrom\s+["'](\.{1,2}\/[^"']+)["']/g, + /\bexport\s+(?:\*|\{[^}]*\})\sfrom\s+["'](\.{1,2}\/[^"']+)["']/g, + /\bimport\s+["'](\.{1,2}\/[^"']+)["']/g, + ]; + + for (const pattern of patterns) { + for (const match of sourceText.matchAll(pattern)) { + specifiers.push(match[1]); + } + } + + return specifiers; +} + +describe("ESM build emits fully-specified relative imports", () => { + const ESM_DIST_DIR = getEsmDistDir(); + + afterAll(() => { + if (!tempDirToCleanup) return; + try { + fs.rmSync(tempDirToCleanup, { recursive: true, force: true }); + } finally { + tempDirToCleanup = null; + } + }); + + it("sets dist/esm/package.json type=module", () => { + const packageJsonPath = path.join(ESM_DIST_DIR, "package.json"); + expect(fs.existsSync(packageJsonPath)).toBe(true); + + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + expect(pkg.type).toBe("module"); + }); + + it("uses .js extensions for all relative ESM specifiers", () => { + expect(fs.existsSync(ESM_DIST_DIR)).toBe(true); + + const esmJsFiles = listFilesRecursive(ESM_DIST_DIR).filter((filePath) => + filePath.endsWith(".js") + ); + expect(esmJsFiles.length).toBeGreaterThan(0); + + const offenders = []; + for (const filePath of esmJsFiles) { + const sourceText = fs.readFileSync(filePath, "utf8"); + const specifiers = getRelativeSpecifiers(sourceText); + + for (const specifier of specifiers) { + if (!specifier.endsWith(".js")) { + offenders.push({ + filePath: path.relative(PROJECT_ROOT, filePath), + specifier, + }); + } + } + } + + expect(offenders).toEqual([]); + }); + + it("exports hooks with .js extensions", () => { + const hooksIndexPath = path.join(ESM_DIST_DIR, "hooks", "index.js"); + expect(fs.existsSync(hooksIndexPath)).toBe(true); + + const sourceText = fs.readFileSync(hooksIndexPath, "utf8"); + expect(sourceText).toContain('export { useHCaptcha } from "./useHCaptcha.js";'); + expect(sourceText).toContain('export { HCaptchaProvider } from "./Provider.js";'); + }); + + // Intentionally avoids executing the built output, since ESM runtime imports + // may fail in CI if peer deps (e.g. react) are not installed. +}); diff --git a/tests/typescript-types.spec.js b/tests/typescript-types.spec.js new file mode 100644 index 0000000..9c16183 --- /dev/null +++ b/tests/typescript-types.spec.js @@ -0,0 +1,160 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { spawnSync } from "child_process"; +import { describe, expect, it } from "@jest/globals"; + +const PROJECT_ROOT = path.resolve(__dirname, ".."); + +function exists(filePath) { + return fs.existsSync(filePath); +} + +function findTsc() { + const localTsc = path.join(PROJECT_ROOT, "node_modules", ".bin", "tsc"); + if (exists(localTsc)) return localTsc; + + const which = spawnSync("which", ["tsc"], { encoding: "utf8" }); + if (which.status === 0) return (which.stdout || "").trim() || null; + + return null; +} + +function getTscVersion(tscPath) { + const result = spawnSync(tscPath, ["-v"], { encoding: "utf8" }); + const text = `${result.stdout || ""}${result.stderr || ""}`.trim(); + const match = text.match(/Version\s+(\d+)\.(\d+)\.(\d+)/i); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; +} + +function isAtLeastVersion(version, major, minor) { + if (!version) return false; + if (version.major !== major) return version.major > major; + return version.minor >= minor; +} + +function tryLinkLocalPackage(tmpRoot) { + const scopeDir = path.join(tmpRoot, "node_modules", "@hcaptcha"); + const linkPath = path.join(scopeDir, "react-hcaptcha"); + + fs.mkdirSync(scopeDir, { recursive: true }); + + try { + // "junction" is the most compatible option across platforms. + fs.symlinkSync(PROJECT_ROOT, linkPath, "junction"); + return true; + } catch { + return false; + } +} + +describe("TypeScript types are present and consumable", () => { + it("package.json points to existing .d.ts files for exports", () => { + const pkg = JSON.parse( + fs.readFileSync(path.join(PROJECT_ROOT, "package.json"), "utf8") + ); + + expect(exists(path.join(PROJECT_ROOT, pkg.types))).toBe(true); + + const rootTypes = pkg.exports?.["."]?.types; + const hooksTypes = pkg.exports?.["./hooks"]?.types; + + expect(typeof rootTypes).toBe("string"); + expect(typeof hooksTypes).toBe("string"); + + expect(exists(path.join(PROJECT_ROOT, rootTypes))).toBe(true); + expect(exists(path.join(PROJECT_ROOT, hooksTypes))).toBe(true); + }); + + it("types/hooks/index.d.ts exports the hook APIs", () => { + const dtsPath = path.join(PROJECT_ROOT, "types", "hooks", "index.d.ts"); + expect(exists(dtsPath)).toBe(true); + + const text = fs.readFileSync(dtsPath, "utf8"); + expect(text).toContain("export function useHCaptcha"); + expect(text).toContain("export function HCaptchaProvider"); + }); + + it("can typecheck a minimal TS consumer project (if tsc is available)", () => { + const tsc = findTsc(); + if (!tsc) { + return; + } + + const version = getTscVersion(tsc); + // TS needs to understand NodeNext + export maps to typecheck real consumers. + // If the only available tsc is too old (common on CI images), skip this check. + if (!isAtLeastVersion(version, 4, 7)) { + return; + } + + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "react-hcaptcha-ts-")); + const srcDir = path.join(tmpRoot, "src"); + fs.mkdirSync(srcDir, { recursive: true }); + + const linked = tryLinkLocalPackage(tmpRoot); + + fs.writeFileSync( + path.join(tmpRoot, "tsconfig.json"), + JSON.stringify( + { + compilerOptions: { + target: "ES2020", + module: "NodeNext", + moduleResolution: "NodeNext", + strict: true, + noEmit: true, + types: [], + skipLibCheck: true, + ...(linked + ? {} + : { + baseUrl: ".", + paths: { + "@hcaptcha/react-hcaptcha": [ + path.join(PROJECT_ROOT, "types", "index.d.ts"), + ], + "@hcaptcha/react-hcaptcha/hooks": [ + path.join(PROJECT_ROOT, "types", "hooks", "index.d.ts"), + ], + }, + }), + }, + include: ["src/**/*.ts"], + }, + null, + 2 + ), + "utf8" + ); + + fs.writeFileSync( + path.join(srcDir, "index.ts"), + [ + 'import HCaptcha from "@hcaptcha/react-hcaptcha";', + 'import { HCaptchaProvider, useHCaptcha } from "@hcaptcha/react-hcaptcha/hooks";', + "", + "void HCaptcha;", + "void HCaptchaProvider;", + "void useHCaptcha;", + ].join("\n"), + "utf8" + ); + + const result = spawnSync(tsc, ["-p", tmpRoot], { + cwd: PROJECT_ROOT, + encoding: "utf8", + }); + + if (result.status !== 0) { + throw new Error( + `TypeScript typecheck failed.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}` + ); + } + }); +});