diff --git a/package.json b/package.json index 29c04232..fc462513 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,30 @@ "types": "./dist/typescript/commonjs/src/components/index.d.ts" } }, + "./components/copyComponentProperties": { + "source": "./src/components/copyComponentProperties.ts", + "react-native": "./src/components/copyComponentProperties.ts", + "import": { + "types": "./dist/typescript/module/src/components/copyComponentProperties.d.ts", + "default": "./dist/module/components/copyComponentProperties.js" + }, + "require": { + "types": "./dist/typescript/commonjs/src/components/copyComponentProperties.d.ts", + "default": "./dist/commonjs/components/copyComponentProperties.js" + } + }, + "./components/index": { + "source": "./src/components/index.cts", + "react-native": "./src/components/index.cts", + "import": { + "types": "./dist/typescript/module/src/components/index.d.ts", + "default": "./dist/module/components/index.cjs" + }, + "require": { + "types": "./dist/typescript/commonjs/src/components/index.d.ts", + "default": "./dist/commonjs/components/index.cjs" + } + }, "./components/react-native-safe-area-context": { "source": "./src/components/react-native-safe-area-context.native.tsx", "react-native": "./src/components/react-native-safe-area-context.native.tsx", diff --git a/src/__tests__/package/exports-source-targets.test.ts b/src/__tests__/package/exports-source-targets.test.ts new file mode 100644 index 00000000..ac409d9e --- /dev/null +++ b/src/__tests__/package/exports-source-targets.test.ts @@ -0,0 +1,132 @@ +/** + * @jest-environment node + */ +import { existsSync, readdirSync } from "node:fs"; +import { join, resolve } from "node:path"; + +import packageJson from "../../../package.json"; + +const PACKAGE_ROOT = resolve(__dirname, "..", "..", ".."); +const COMPONENTS_DIRECTORY = join(PACKAGE_ROOT, "src", "components"); + +type ExportsEntry = string | { readonly [condition: string]: ExportsEntry }; + +const EXPORTS_MAP = packageJson.exports as Readonly< + Record +>; + +/** Only these two conditions name a path inside `src/`; every other one names a build artifact. */ +const SOURCE_CONDITIONS = ["source", "react-native"] as const; + +const sourceTargetsOf = (entry: ExportsEntry): readonly string[] => { + if (typeof entry === "string") return []; + return SOURCE_CONDITIONS.flatMap((condition) => { + const value = entry[condition]; + return typeof value === "string" ? [value] : []; + }); +}; + +const substituteWildcard = ( + entry: ExportsEntry, + wildcard: string, +): ExportsEntry => { + if (typeof entry === "string") return entry.replace("*", wildcard); + return Object.fromEntries( + Object.entries(entry).map(([condition, value]) => [ + condition, + substituteWildcard(value, wildcard), + ]), + ); +}; + +/** Node prefers an exact key, then the pattern whose literal prefix before `*` is longest. */ +const resolveSubpath = (subpath: string): ExportsEntry | undefined => { + const exact = EXPORTS_MAP[subpath]; + if (exact !== undefined) return exact; + + let longestPrefix = -1; + let matched: ExportsEntry | undefined; + for (const [key, entry] of Object.entries(EXPORTS_MAP)) { + const star = key.indexOf("*"); + if (star === -1) continue; + const prefix = key.slice(0, star); + const suffix = key.slice(star + 1); + if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue; + if (subpath.length < prefix.length + suffix.length) continue; + if (prefix.length <= longestPrefix) continue; + longestPrefix = prefix.length; + matched = substituteWildcard( + entry, + subpath.slice(prefix.length, subpath.length - suffix.length), + ); + } + return matched; +}; + +const existenceOf = ( + subpath: string, + targets: readonly string[], +): readonly { + readonly subpath: string; + readonly target: string; + readonly exists: boolean; +}[] => + targets.map((target) => ({ + subpath, + target, + exists: existsSync(join(PACKAGE_ROOT, target)), + })); + +const allExist = ( + subpath: string, + targets: readonly string[], +): readonly { + readonly subpath: string; + readonly target: string; + readonly exists: boolean; +}[] => targets.map((target) => ({ subpath, target, exists: true })); + +const literalEntries = Object.entries(EXPORTS_MAP).filter( + ([key]) => !key.includes("*"), +); + +/** A consumer imports `./components/`, so two modules differing only by extension are one import. */ +const componentSubpaths = [ + ...new Set( + readdirSync(COMPONENTS_DIRECTORY).map( + (file) => `./components/${file.replace(/\.[^.]+$/u, "")}`, + ), + ), +].sort(); + +describe("exports source targets", () => { + it("has subpaths and component modules to check", () => { + expect(literalEntries.length).toBeGreaterThan(0); + expect(componentSubpaths.length).toBeGreaterThan(0); + }); + + it.each(literalEntries)( + "%s names source files that exist", + (subpath, entry) => { + const targets = sourceTargetsOf(entry); + expect(existenceOf(subpath, targets)).toStrictEqual( + allExist(subpath, targets), + ); + }, + ); + + it.each(componentSubpaths)( + "%s resolves to a source file that exists", + (subpath) => { + const entry = resolveSubpath(subpath); + if (entry === undefined) + throw new Error(`${subpath} resolves through no exports entry`); + + const targets = sourceTargetsOf(entry); + expect(targets.length).toBeGreaterThan(0); + expect(existenceOf(subpath, targets)).toStrictEqual( + allExist(subpath, targets), + ); + }, + ); +});