Skip to content

Commit

Permalink
Support .d.ts files on entrypoints (#474)
Browse files Browse the repository at this point in the history
  • Loading branch information
emmatown committed Jul 12, 2022
1 parent c300a14 commit a05414d
Show file tree
Hide file tree
Showing 6 changed files with 242 additions and 80 deletions.
5 changes: 5 additions & 0 deletions .changeset/dirty-bottles-pretend.md
@@ -0,0 +1,5 @@
---
"@preconstruct/cli": minor
---

`.d.ts` files can now be written next to `.js` entrypoints and they will be appropriately written to the dist.
45 changes: 44 additions & 1 deletion packages/cli/src/__tests__/dev.ts
Expand Up @@ -2,7 +2,7 @@ import spawn from "spawndamnit";
import path from "path";
import * as fs from "fs-extra";
import * as realFs from "fs";
import { getFiles, js, testdir, typescriptFixture } from "../../test-utils";
import { getFiles, js, testdir, ts, typescriptFixture } from "../../test-utils";
import dev from "../dev";
import normalizePath from "normalize-path";
import escapeStringRegexp from "escape-string-regexp";
Expand Down Expand Up @@ -352,3 +352,46 @@ test("exports field with worker condition", async () => {
})
);
});

test("flow and .d.ts", async () => {
let tmpPath = await testdir({
"package.json": JSON.stringify({
name: "pkg",
main: "dist/pkg.cjs.js",
module: "dist/pkg.esm.js",
}),
"src/index.js": js`
// @flow
export const x = "hello";
`,
"src/index.d.ts": ts`
export const x: string;
`,
});
await dev(tmpPath);
const files = await getFiles(tmpPath, ["dist/**", "!dist/pkg.cjs.js"]);
expect(files).toMatchInlineSnapshot(`
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/pkg.cjs.d.ts ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
// are you seeing an error that a default export doesn't exist but your source file has a default export?
// you should run \`yarn\` or \`yarn preconstruct dev\` if preconstruct dev isn't in your postinstall hook
// curious why you need to?
// this file exists so that you can import from the entrypoint normally
// except that it points to your source file and you don't need to run build constantly
// which means we need to re-export all of the modules from your source file
// and since export * doesn't include default exports, we need to read your source file
// to check for a default export and re-export it if it exists
// it's not ideal, but it works pretty well ¯\\_(ツ)_/¯
export * from "../src/index.js";
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/pkg.cjs.js.flow ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
// @flow
export * from "../src/index.js";
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/pkg.esm.js ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
// @flow
export const x = "hello";
`);
});
50 changes: 50 additions & 0 deletions packages/cli/src/build/__tests__/other.ts
Expand Up @@ -9,6 +9,7 @@ import {
ts,
repoNodeModules,
typescriptFixture,
getFiles,
} from "../../../test-utils";
import { doPromptInput } from "../../prompt";

Expand Down Expand Up @@ -727,3 +728,52 @@ test("fails for source files containing top-level this", async () => {
}
expect(true).toBe(false);
});

test(".d.ts", async () => {
const dir = await testdir({
"package.json": JSON.stringify({
name: "pkg",
main: "dist/pkg.cjs.js",
module: "dist/pkg.esm.js",
}),
"src/index.js": js`
export const x = "hello";
`,
"src/index.d.ts": ts`
export const x: string;
`,
node_modules: { kind: "symlink", path: repoNodeModules },
"tsconfig.json": typescriptFixture["tsconfig.json"],
});
await build(dir);
expect(await getFiles(dir, ["dist/**"])).toMatchInlineSnapshot(`
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/declarations/src/index.d.ts ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
export const x: string;
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/pkg.cjs.d.ts ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
export * from "./declarations/src/index";
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/pkg.cjs.dev.js, dist/pkg.cjs.prod.js ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
const x = "hello";
exports.x = x;
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/pkg.cjs.js ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
'use strict';
if (process.env.NODE_ENV === "production") {
module.exports = require("./pkg.cjs.prod.js");
} else {
module.exports = require("./pkg.cjs.dev.js");
}
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/pkg.esm.js ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
const x = "hello";
export { x };
`);
});
102 changes: 58 additions & 44 deletions packages/cli/src/dev.ts
Expand Up @@ -15,20 +15,36 @@ import { validateProject } from "./validate";

let tsExtensionPattern = /tsx?$/;

async function getTypeSystem(
entrypoint: Entrypoint
): Promise<[undefined | "flow" | "typescript", string]> {
let content = await fs.readFile(entrypoint.source, "utf8");
type TypeSystemInfo = {
flow: boolean;
typescript: false | string;
};

async function getTypeSystem(entrypoint: Entrypoint): Promise<TypeSystemInfo> {
let sourceContents = await fs.readFile(entrypoint.source, "utf8");
const ret: TypeSystemInfo = {
flow: false,
typescript: false,
};
if (tsExtensionPattern.test(entrypoint.source)) {
return ["typescript", content];
ret.typescript = sourceContents;
} else {
try {
let tsSourceContents = await fs.readFile(
entrypoint.source.replace(/\.jsx?/, ".d.ts"),
"utf8"
);
ret.typescript = tsSourceContents;
} catch (err) {
if (err.code !== "ENOENT") {
throw err;
}
}
}
// TODO: maybe we should write the flow symlink even if there isn't an @flow
// comment so that if someone adds an @flow comment they don't have to run preconstruct dev again
if (content.includes("@flow")) {
return ["flow", content];
if (sourceContents.includes("@flow")) {
ret.flow = true;
}
return [undefined, content];
return ret;
}

async function entrypointHasDefaultExport(
Expand Down Expand Up @@ -112,43 +128,30 @@ export async function writeDevTSFile(
await fs.outputFile(cjsDistPath, output);
}

async function writeTypeSystemFile(
typeSystemPromise: Promise<[undefined | "flow" | "typescript", string]>,
entrypoint: Entrypoint
) {
let [typeSystem, content] = await typeSystemPromise;
if (typeSystem === undefined) return;
async function writeDevFlowFile(entrypoint: Entrypoint) {
// so...
// you might have noticed that this passes
// hasExportDefault=false
// and be thinking that default exports
// but flow seems to be
// then you might ask, if re-exporting the default
// export isn't necessary, why do it for actual builds?
// the reason is is that if preconstruct dev breaks because
// of a new version of flow that changes this, that's mostly okay
// because preconstruct dev can be fixed, a consumer can upgrade it
// and then everything is fine but if a production build is broken
// a consumer would have to do a new release and that's not ideal
let cjsDistPath = path.join(
entrypoint.directory,
validFieldsForEntrypoint.main(entrypoint)
);

if (typeSystem === "flow") {
// so...
// you might have noticed that this passes
// hasExportDefault=false
// and be thinking that default exports
// but flow seems to be
// then you might ask, if re-exporting the default
// export isn't necessary, why do it for actual builds?
// the reason is is that if preconstruct dev breaks because
// of a new version of flow that changes this, that's mostly okay
// because preconstruct dev can be fixed, a consumer can upgrade it
// and then everything is fine but if a production build is broken
// a consumer would have to do a new release and that's not ideal
await fs.writeFile(
cjsDistPath + ".flow",
flowTemplate(
false,
normalizePath(
path.relative(path.dirname(cjsDistPath), entrypoint.source)
)
)
);
}
if (typeSystem === "typescript") {
await writeDevTSFile(entrypoint, content);
}
await fs.writeFile(
cjsDistPath + ".flow",
flowTemplate(
false,
normalizePath(path.relative(path.dirname(cjsDistPath), entrypoint.source))
)
);
}

export default async function dev(projectDir: string) {
Expand All @@ -169,7 +172,18 @@ export default async function dev(projectDir: string) {
await fs.ensureDir(distDirectory);

let promises = [
writeTypeSystemFile(typeSystemPromise, entrypoint),
typeSystemPromise.then((typeSystemInfo) => {
let promises = [];
if (typeSystemInfo.flow) {
promises.push(writeDevFlowFile(entrypoint));
}
if (typeSystemInfo.typescript !== false) {
promises.push(
writeDevTSFile(entrypoint, typeSystemInfo.typescript)
);
}
return Promise.all(promises);
}),
fs.writeFile(
path.join(
entrypoint.directory,
Expand Down
Expand Up @@ -14,7 +14,10 @@ export async function getDeclarations(
pkgName: string,
projectDir: string,
entrypoints: string[]
): Promise<EmittedDeclarationOutput[]> {
): Promise<{
entrypointSourceToTypeScriptSource: ReadonlyMap<string, string>;
declarations: EmittedDeclarationOutput[];
}> {
const typescript = loadTypeScript(dirname, projectDir, pkgName);

const { program, options } = await getProgram(dirname, pkgName, typescript);
Expand All @@ -25,22 +28,28 @@ export async function getDeclarations(
);
let normalizedDirname = normalizePath(dirname);

let resolvedEntrypointPaths = entrypoints.map((x) => {
let { resolvedModule } = typescript.resolveModuleName(
path.join(path.dirname(x), path.basename(x, path.extname(x))),
dirname,
options,
typescript.sys,
moduleResolutionCache
);
if (!resolvedModule) {
throw new Error(
"This is an internal error, please open an issue if you see this: ts could not resolve module"
// these will be distinct when using .d.ts files
const entrypointSourceToTypeScriptSource: ReadonlyMap<
string,
string
> = new Map(
entrypoints.map((x) => {
let { resolvedModule } = typescript.resolveModuleName(
path.join(path.dirname(x), path.basename(x, path.extname(x))),
dirname,
options,
typescript.sys,
moduleResolutionCache
);
}
return resolvedModule.resolvedFileName;
});
let allDeps = new Set<string>(resolvedEntrypointPaths);
if (!resolvedModule) {
throw new Error(
"This is an internal error, please open an issue if you see this: ts could not resolve module"
);
}
return [normalizePath(x), resolvedModule.resolvedFileName];
})
);
let allDeps = new Set<string>(entrypointSourceToTypeScriptSource.values());

function searchDeps(deps: Set<string>) {
for (let dep of deps) {
Expand Down Expand Up @@ -79,20 +88,23 @@ export async function getDeclarations(
searchDeps(internalDeps);
}
}
searchDeps(new Set(resolvedEntrypointPaths));
searchDeps(new Set(entrypointSourceToTypeScriptSource.values()));

const diagnosticsHost = getDiagnosticsHost(typescript, projectDir);

return Promise.all(
[...allDeps].map((filename) => {
return getDeclarationsForFile(
filename,
typescript,
program,
normalizedDirname,
projectDir,
diagnosticsHost
);
})
);
return {
entrypointSourceToTypeScriptSource,
declarations: await Promise.all(
[...allDeps].map((filename) => {
return getDeclarationsForFile(
filename,
typescript,
program,
normalizedDirname,
projectDir,
diagnosticsHost
);
})
),
};
}

0 comments on commit a05414d

Please sign in to comment.