From 2422e526257d971ba382bb9195e0ee527b918672 Mon Sep 17 00:00:00 2001 From: Kitson Kelly Date: Wed, 31 Oct 2018 14:50:40 +1100 Subject: [PATCH] Add ability to load JSON as modules (#1065) --- js/compiler.ts | 122 +++++++++++++++++++++++----------- js/compiler_test.ts | 72 ++++++++++++++++++-- tests/020_json_modules.ts | 3 + tests/020_json_modules.ts.out | 1 + tests/subdir/config.json | 6 ++ tsconfig.json | 1 + 6 files changed, 161 insertions(+), 44 deletions(-) create mode 100644 tests/020_json_modules.ts create mode 100644 tests/020_json_modules.ts.out create mode 100644 tests/subdir/config.json diff --git a/js/compiler.ts b/js/compiler.ts index 20ddbae9fc3bab..88a383e4b60c48 100644 --- a/js/compiler.ts +++ b/js/compiler.ts @@ -79,7 +79,7 @@ export interface Ts { */ export class ModuleMetaData implements ts.IScriptSnapshot { public deps?: ModuleFileName[]; - public readonly exports = {}; + public exports = {}; public factory?: AmdFactory; public gatheringDeps = false; public hasRun = false; @@ -129,6 +129,15 @@ function getExtension( } } +/** Generate output code for a provided JSON string along with its source. */ +export function jsonAmdTemplate( + jsonString: string, + sourceFileName: string +): OutputCode { + // tslint:disable-next-line:max-line-length + return `define([], function() { return JSON.parse(\`${jsonString}\`); });\n//# sourceURL=${sourceFileName}`; +} + /** A singleton class that combines the TypeScript Language Service host API * with Deno specific APIs to provide an interface for compiling and running * TypeScript and JavaScript modules. @@ -153,11 +162,12 @@ export class DenoCompiler >(); // TODO ideally this are not static and can be influenced by command line // arguments - private readonly _options: Readonly = { + private readonly _options: ts.CompilerOptions = { allowJs: true, checkJs: true, module: ts.ModuleKind.AMD, outDir: "$deno$", + resolveJsonModule: true, sourceMap: true, stripComments: true, target: ts.ScriptTarget.ESNext @@ -198,7 +208,15 @@ export class DenoCompiler ); assert(moduleMetaData.hasRun === false, "Module has already been run."); // asserts not tracked by TypeScripts, so using not null operator - moduleMetaData.factory!(...this._getFactoryArguments(moduleMetaData)); + const exports = moduleMetaData.factory!( + ...this._getFactoryArguments(moduleMetaData) + ); + // For JSON module support and potential future features. + // TypeScript always imports `exports` and mutates it directly, but the + // AMD specification allows values to be returned from the factory. + if (exports != null) { + moduleMetaData.exports = exports; + } moduleMetaData.hasRun = true; } } @@ -421,50 +439,74 @@ export class DenoCompiler if (!recompile && moduleMetaData.outputCode) { return moduleMetaData.outputCode; } - const { fileName, sourceCode, moduleId } = moduleMetaData; + const { fileName, sourceCode, mediaType, moduleId } = moduleMetaData; console.warn("Compiling", moduleId); const service = this._service; - const output = service.getEmitOutput(fileName); - - // Get the relevant diagnostics - this is 3x faster than - // `getPreEmitDiagnostics`. - const diagnostics = [ - ...service.getCompilerOptionsDiagnostics(), - ...service.getSyntacticDiagnostics(fileName), - ...service.getSemanticDiagnostics(fileName) - ]; - if (diagnostics.length > 0) { - const errMsg = this._ts.formatDiagnosticsWithColorAndContext( - diagnostics, - this + // Instead of using TypeScript to transpile JSON modules, we will just do + // it directly. + if (mediaType === MediaType.Json) { + moduleMetaData.outputCode = jsonAmdTemplate(sourceCode, fileName); + } else { + assert( + mediaType === MediaType.TypeScript || mediaType === MediaType.JavaScript ); - console.log(errMsg); - // All TypeScript errors are terminal for deno - this._os.exit(1); - } + // TypeScript is overly opinionated that only CommonJS modules kinds can + // support JSON imports. Allegedly this was fixed in + // Microsoft/TypeScript#26825 but that doesn't seem to be working here, + // so we will trick the TypeScript compiler. + this._options.module = ts.ModuleKind.AMD; + const output = service.getEmitOutput(fileName); + this._options.module = ts.ModuleKind.CommonJS; + + // Get the relevant diagnostics - this is 3x faster than + // `getPreEmitDiagnostics`. + const diagnostics = [ + ...service.getCompilerOptionsDiagnostics(), + ...service.getSyntacticDiagnostics(fileName), + ...service.getSemanticDiagnostics(fileName) + ]; + if (diagnostics.length > 0) { + const errMsg = this._ts.formatDiagnosticsWithColorAndContext( + diagnostics, + this + ); + console.log(errMsg); + // All TypeScript errors are terminal for deno + this._os.exit(1); + } - assert(!output.emitSkipped, "The emit was skipped for an unknown reason."); + assert( + !output.emitSkipped, + "The emit was skipped for an unknown reason." + ); - assert( - output.outputFiles.length === 2, - `Expected 2 files to be emitted, got ${output.outputFiles.length}.` - ); + assert( + output.outputFiles.length === 2, + `Expected 2 files to be emitted, got ${output.outputFiles.length}.` + ); + + const [sourceMapFile, outputFile] = output.outputFiles; + assert( + sourceMapFile.name.endsWith(".map"), + "Expected first emitted file to be a source map" + ); + assert( + outputFile.name.endsWith(".js"), + "Expected second emitted file to be JavaScript" + ); + moduleMetaData.outputCode = `${ + outputFile.text + }\n//# sourceURL=${fileName}`; + moduleMetaData.sourceMap = sourceMapFile.text; + } - const [sourceMapFile, outputFile] = output.outputFiles; - assert( - sourceMapFile.name.endsWith(".map"), - "Expected first emitted file to be a source map" - ); - assert( - outputFile.name.endsWith(".js"), - "Expected second emitted file to be JavaScript" - ); - const outputCode = (moduleMetaData.outputCode = `${ - outputFile.text - }\n//# sourceURL=${fileName}`); - const sourceMap = (moduleMetaData.sourceMap = sourceMapFile.text); moduleMetaData.scriptVersion = "1"; - this._os.codeCache(fileName, sourceCode, outputCode, sourceMap); + this._os.codeCache( + fileName, + sourceCode, + moduleMetaData.outputCode, + moduleMetaData.sourceMap + ); return moduleMetaData.outputCode; } diff --git a/js/compiler_test.ts b/js/compiler_test.ts index d7a5c877c425b8..729d6b4a71e787 100644 --- a/js/compiler_test.ts +++ b/js/compiler_test.ts @@ -6,7 +6,7 @@ import * as ts from "typescript"; // We use a silly amount of `any` in these tests... // tslint:disable:no-any -const { DenoCompiler } = (deno as any)._compiler; +const { DenoCompiler, jsonAmdTemplate } = (deno as any)._compiler; interface ModuleInfo { moduleName: string | undefined; @@ -118,6 +118,11 @@ const fooBazTsOutput = `define(["require", "exports", "./bar.ts"], function (req // This is not a valid map, just mock data const fooBazTsSourcemap = `{"version":3,"file":"baz.js","sourceRoot":"","sources":["file:///root/project/foo/baz.ts"],"names":[],"mappings":""}`; + +const loadConfigSource = `import * as config from "./config.json"; +console.log(config.foo.baz); +`; +const configJsonSource = `{"foo":{"bar": true,"baz": ["qat", 1]}}`; // tslint:enable:max-line-length const moduleMap: { @@ -148,6 +153,14 @@ const moduleMap: { "console.log();", null, null + ), + "loadConfig.ts": mockModuleInfo( + "/root/project/loadConfig.ts", + "/root/project/loadConfig.ts", + MediaType.TypeScript, + loadConfigSource, + null, + null ) }, "/root/project/foo/baz.ts": { @@ -166,6 +179,16 @@ const moduleMap: { "/root/project/modB.ts": { "./modA.ts": modAModuleInfo }, + "/root/project/loadConfig.ts": { + "./config.json": mockModuleInfo( + "/root/project/config.json", + "/root/project/config.json", + MediaType.Json, + configJsonSource, + null, + null + ) + }, "/moduleKinds": { "foo.ts": mockModuleInfo( "foo", @@ -280,7 +303,7 @@ const osMock = { return mockModuleInfo(null, null, null, null, null, null); }, exit(code: number): never { - throw new Error(`os.exit(${code})`); + throw new Error(`Unexpected call to os.exit(${code})`); } }; const tsMock = { @@ -289,9 +312,9 @@ const tsMock = { }, formatDiagnosticsWithColorAndContext( diagnostics: ReadonlyArray, - host: ts.FormatDiagnosticsHost + _host: ts.FormatDiagnosticsHost ): string { - return ""; + return JSON.stringify(diagnostics.map(({ messageText }) => messageText)); } }; @@ -374,6 +397,23 @@ function teardown() { Object.assign(compilerInstance, originals); } +test(function testJsonAmdTemplate() { + let deps: string[]; + let factory: Function; + function define(d: string[], f: Function) { + deps = d; + factory = f; + } + + const code = jsonAmdTemplate(`{ "hello": "world", "foo": "bar" }`); + const result = eval(code); + assert(result == null); + assertEqual(deps && deps.length, 0); + assert(factory != null); + const factoryResult = factory(); + assertEqual(factoryResult, { hello: "world", foo: "bar" }); +}); + test(function compilerInstance() { assert(DenoCompiler != null); assert(DenoCompiler.instance() != null); @@ -479,6 +519,29 @@ test(function compilerRunCircularDependency() { teardown(); }); +test(function compilerLoadJsonModule() { + setup(); + const factoryStack: string[] = []; + const configJsonDeps: string[] = []; + const configJsonFactory = () => { + factoryStack.push("configJson"); + return JSON.parse(configJsonSource); + }; + const loadConfigDeps = ["require", "exports", "./config.json"]; + const loadConfigFactory = (_require, _exports, _config) => { + factoryStack.push("loadConfig"); + assertEqual(_config, JSON.parse(configJsonSource)); + }; + + mockDepsStack.push(configJsonDeps); + mockFactoryStack.push(configJsonFactory); + mockDepsStack.push(loadConfigDeps); + mockFactoryStack.push(loadConfigFactory); + compilerInstance.run("loadConfig.ts", "/root/project"); + assertEqual(factoryStack, ["configJson", "loadConfig"]); + teardown(); +}); + test(function compilerResolveModule() { setup(); const moduleMetaData = compilerInstance.resolveModule( @@ -544,6 +607,7 @@ test(function compilerGetCompilationSettings() { "checkJs", "module", "outDir", + "resolveJsonModule", "sourceMap", "stripComments", "target" diff --git a/tests/020_json_modules.ts b/tests/020_json_modules.ts new file mode 100644 index 00000000000000..89963751c034fc --- /dev/null +++ b/tests/020_json_modules.ts @@ -0,0 +1,3 @@ +import * as config from "./subdir/config.json"; + +console.log(JSON.stringify(config)); diff --git a/tests/020_json_modules.ts.out b/tests/020_json_modules.ts.out new file mode 100644 index 00000000000000..5d1623e6b61d13 --- /dev/null +++ b/tests/020_json_modules.ts.out @@ -0,0 +1 @@ +{"foo":{"bar":true,"baz":["qat",1]}} diff --git a/tests/subdir/config.json b/tests/subdir/config.json new file mode 100644 index 00000000000000..01c3b5e79adf86 --- /dev/null +++ b/tests/subdir/config.json @@ -0,0 +1,6 @@ +{ + "foo": { + "bar": true, + "baz": ["qat", 1] + } +} diff --git a/tsconfig.json b/tsconfig.json index 806143644c930d..c67223f5303dfd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,7 @@ "preserveConstEnums": true, "pretty": true, "removeComments": true, + "resolveJsonModule": true, "sourceMap": true, "strict": true, "target": "esnext",