Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support .d.ts files on entrypoints #474

Merged
merged 3 commits into from Jul 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
);
})
),
};
}