commit 8b9a6e999772bc02148b6764905d8b52bdbbf046 Author: Josh Goldberg Date: Thu Jan 4 05:06:29 2018 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..697d807 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +dist/ +docs/generated/ +test/ +node_modules/ +*.css +*.d.ts +*.js* +!./*.js +!*.json +*.html +npm-debug.log +debug.log + +# Added by shenanigans-manager for maps testing +Maps.test.ts + +# Local development typically uses npm install --link +# Package lock files aren't updated by linked installs +package-lock.json +yarn.lock diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..63c7b73 --- /dev/null +++ b/.npmignore @@ -0,0 +1,4 @@ +node_modules/ +test/ +*.test.* +npm-debug.log diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..5af8673 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +unsafe-perm = true diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..fc9e5c3 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,8 @@ +language: node_js + +node_js: + - "node" + - "7" + +script: + npm run setup && npm run verify diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..89a6809 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.tabSize": 4, + "editor.trimAutoWhitespace": true, + "tslint.alwaysShowRuleFailuresAsWarnings": true, + "tslint.autoFixOnSave": true, + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..e4fcb9d --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,18 @@ +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3571dc2 --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ + +# BabyIoC +[![Greenkeeper badge](https://badges.greenkeeper.io/FullScreenShenanigans/BabyIoC.svg)](https://greenkeeper.io/) +[![Build Status](https://travis-ci.org/FullScreenShenanigans/BabyIoC.svg?branch=master)](https://travis-ci.org/FullScreenShenanigans/BabyIoC) +[![NPM version](https://badge.fury.io/js/babyioc.svg)](http://badge.fury.io/js/babyioc) + +Infantile IoC decorator with almost no features. + + +BabyIoC is the smallest IoC container you'll ever see _(about 100 real lines of code!)_. +It's also got the fewest toys - it's only targeted for use by [GameStartr](https://github.com/FullScreenShenanigans/GameStartr). + +Key tenants: +* Use TypeScript. +* All `@components` are members of the parent `@container` container class instance. +* Components and component `@dependency`s are stored as lazily evaluated getters: circular dependencies are fine! + +## Usage + +Each **@component** is a member of your root **@container** class. +Declare your components with their classes to have them automagically created as members of your class. + +```typescript +import { component, container } from "babyioc"; + +class DependencyA { } + +@container +class Container { + @component(DependencyA) + public readonly dependencyA: DependencyA; +} + +const { dependencyA } = new Container(); +``` + +Components can take each other in as **`@dependency`s. + +```typescript +class DependencyA { } +class DependencyB { + @dependency(DependencyA) + public readonly dependencyA: DependencyA; +} + +@container +class Container { + @component(DependencyB) + public readonly dependencyB: DependencyB; + + @component(DependencyA) + public readonly dependencyA: DependencyA; +} + +const { dependencyA, dependencyB } = new Container(); +``` + +### Factories + +Your components don't have to be direct classes with dependencies. +Pass functions that take in your container as an argument. + +If you're using anonymous `() =>` arrow lambdas, because they don't have names, pass in the class name or a string identifier to store them under internally. + +```typescript +class DependencyA { + public constructor( + public readonly member: string, + ) { } +} +const memberValue = "memberValue"; +const createDependencyA = () => new DependencyA(memberValue); + +@container +class Container { + @component(createDependencyA, DependencyA) + public readonly dependencyA: DependencyA; +} + +const { dependencyA } = new Container(); +``` + +These factory functions have access to all the values on the container, including computed getters. + +```typescript +class DependencyA { + public constructor( + public readonly memberA: string, + ) { } +} +class DependencyB { + public constructor( + public readonly referenceA: DependencyA, + public readonly valueC: string, + ) { } +} +const memberValueA = "memberValueA"; +const createDependencyA = () => new DependencyA(memberValueA); +const createDependencyB = (instance: Container) => new DependencyB(dependencyA, container.valueC); + +@container +class Container { + @component(createDependencyA, DependencyA) + public readonly dependencyA: DependencyA; + + @component(createDependencyB, DependencyB) + public readonly dependencyB: DependencyB; + + public readonly valueC = "valueC"; +} + +const { dependencyA, dependencyB } = new Container(); +``` + + +## Development + +``` +git clone https://github.com/FullScreenShenanigans/BabyIoC +cd BabyIoC +npm run setup +npm run verify +``` + +* `npm run setup` creates a few auto-generated setup files locally. +* `npm run verify` builds, lints, and runs tests. + +### Building + +```shell +npm run watch +``` + +Source files are written under `src/` in TypeScript and compile in-place to JavaScript files. +`npm run watch` will directly run the TypeScript compiler on source files in watch mode. +Use it in the background while developing to keep the compiled files up-to-date. + +### Running Tests + +```shell +npm run test +``` + +Test files are alongside source files under `src/` and named `*.test.ts?`. +Whenever you add, remove, or rename a `*.test.ts?` file under `src/`, re-run `npm run test:setup` to regenerate the list of static test files in `test/index.html`. +You can open that file in a browser to debug through the tests. +`npm run test` will run that setup and execute tests using [Puppeteer](https://github.com/GoogleChrome/puppeteer). + diff --git a/package.json b/package.json new file mode 100644 index 0000000..7a31ae8 --- /dev/null +++ b/package.json @@ -0,0 +1,72 @@ +{ + "author": { + "email": "joshuakgoldberg@outlook.com", + "name": "Josh Goldberg" + }, + "browser": "./src/index.js", + "bugs": { + "url": "https://github.com/FullScreenShenanigans/BabyIoC/issues" + }, + "dependencies": { + }, + "description": "Infantile IoC decorator with almost no features.", + "devDependencies": { + "@types/chai": "^4.0.4", + "@types/lolex": "^2.1.1", + "@types/mocha": "^2.2.44", + "@types/sinon": "^4.0.0", + "@types/sinon-chai": "^2.7.29", + "chai": "^4.1.2", + "glob": "^7.1.2", + "lolex": "^2.3.0", + "mkdirp": "^0.5.1", + "mocha": "^4.0.1", + "mocha-headless-chrome": "^1.7.1", + "requirejs": "^2.3.5", + "run-for-every-file": "^1.1.0", + "shenanigans-manager": "^0.2.7", + "sinon": "^4.1.2", + "sinon-chai": "^2.14.0", + "tslint": "5.8.0", + "tsutils": "^2.14.0", + "typedoc": "^0.9.0", + "typescript": "^2.6.2", + "webpack": "^3.10.0" + }, + "license": "MIT", + "name": "babyioc", + "repository": { + "type": "git", + "url": "ssh://git@github.com:FullScreenShenanigans/BabyIoC.git" + }, + "scripts": { + "dist": "npm run dist:webpack", + "dist:webpack": "webpack", + "docs": "npm run docs:typedoc", + "docs:typedoc": "typedoc src/ --exclude **/*.d.ts --ignoreCompilerErrors --out docs/generated", + "init": "npm install && npm run setup && npm run verify", + "setup": "npm run setup:copy && npm run setup:package && npm run setup:readme", + "setup:copy": "npm run setup:copy:default", + "setup:copy:default": "run-for-every-file --dot --src \"node_modules/shenanigans-manager/setup/default/\" --file \"**/*\" --run \"mustache package.json {{src-file}} {{file}}\" --dest \".\" --only-files", + "setup:package": "shenanigans-manager hydrate-package-json", + "setup:readme": "shenanigans-manager hydrate-readme", + "src": "npm run src:tsc && npm run src:tslint", + "src:tsc": "tsc -p .", + "src:tslint": "tslint -c tslint.json -e ./node_modules/**/*.ts* -p tsconfig.json -t stylish", + "test": "npm run test:setup && npm run test:run", + "test:run": "mocha-headless-chrome --file test/index.html", + "test:setup": "npm run test:setup:dir && npm run test:setup:copy && npm run test:setup:html && npm run test:setup:tsc", + "test:setup:copy": "npm run test:setup:copy:default", + "test:setup:copy:default": "run-for-every-file --dot --src \"node_modules/shenanigans-manager/setup/test/\" --file \"**/*\" --run \"mustache package.json {{src-file}} ./test/{{file}}\" --dest \".\" --only-files", + "test:setup:dir": "mkdirp test", + "test:setup:html": "shenanigans-manager generate-test-html", + "test:setup:tsc": "tsc -p test", + "verify": "npm run src && npm run test && npm run dist && npm run docs", + "watch": "tsc -p . -w" + }, + "shenanigans": { + "name": "BabyIoC" + }, + "types": "./src/index.d.ts", + "version": "0.7.0" +} \ No newline at end of file diff --git a/src/Component.ts b/src/Component.ts new file mode 100644 index 0000000..b8024b0 --- /dev/null +++ b/src/Component.ts @@ -0,0 +1,69 @@ +import { getFunctionName } from "./Reading"; + +export type IContainerClass = IClass & { + __listings__?: { [i: string]: IComponentListing }; +}; + +export type IClass = IClassWithArgs | IClassWithoutArgs; + +export interface IClassWithArgs { + new(...args: any[]): any; +} + +export interface IClassWithoutArgs { + new(): any; +} + +export type IComponentClassOrFunction = IComponentClass | IComponentFunction; + +export interface IComponentClass { + new(container: IClass): any; +} + +export type IComponentFunction = (container: IClass) => any; + +/** + * Describes how to create and store a component. + */ +export interface IComponentListing { + /** + * Class or function to create the component. + */ + componentFunction: IComponentClassOrFunction; + + /** + * Unique name for the class or function to create this component. + */ + listingName: string; + + /** + * Member property name the component will be stored under. + */ + memberName: string; +} + +/** + * Adds a member component to a parent container. + * + * @param componentFunction Class or function that creates the component. + * @param name Name to store the component under, if not the class' or function's .name. + */ +export const component = (componentFunction: any /* IComponentClassOrFunction */, name?: string | Function) => { + if (typeof name === "function") { + name = getFunctionName(name); + } + + const listingName = name === undefined + ? getFunctionName(componentFunction) + : name; + + return (parentClass: any /* IContainerClass */, memberName: string) => { + const listing: IComponentListing = { componentFunction, listingName, memberName }; + + if (parentClass.__listings__ === undefined) { + parentClass.__listings__ = {}; + } + + parentClass.__listings__[listing.listingName] = listing; + }; +}; diff --git a/src/Container.test.ts b/src/Container.test.ts new file mode 100644 index 0000000..65b7f4b --- /dev/null +++ b/src/Container.test.ts @@ -0,0 +1,163 @@ +import { expect } from "chai"; + +import { component } from "./Component"; +import { container } from "./Container"; +import { dependency } from "./Dependency"; + +describe("container", () => { + it("resolves a component dependency", () => { + // Arrange + class DependencyA { } + + @container + class Container { + @component(DependencyA) + public readonly dependencyA: DependencyA; + } + + // Act + const { dependencyA } = new Container(); + + // Assert + expect(dependencyA).to.be.instanceOf(DependencyA); + }); + + it("resolves two component dependencies out of alphabetical order", () => { + // Arrange + class DependencyA { } + class DependencyB { } + + @container + class Container { + @component(DependencyB) + public readonly dependencyB: DependencyB; + + @component(DependencyA) + public readonly dependencyA: DependencyA; + } + + // Act + const { dependencyA, dependencyB } = new Container(); + + // Assert + expect(dependencyA).to.be.instanceOf(DependencyA); + expect(dependencyB).to.be.instanceOf(DependencyB); + }); + + it("adds a dependency to a component", () => { + // Arrange + class DependencyA { } + class DependencyB { + @dependency(DependencyA) + public readonly dependencyA: DependencyA; + } + + @container + class Container { + @component(DependencyB) + public readonly dependencyB: DependencyB; + + @component(DependencyA) + public readonly dependencyA: DependencyA; + } + + // Act + const { dependencyA, dependencyB } = new Container(); + + // Assert + expect(dependencyB.dependencyA).to.be.equal(dependencyA); + }); + + it("creates a component using a factory", () => { + // Arrange + class DependencyA { + public constructor( + public readonly member: string, + ) { } + } + const memberValue = "memberValue"; + const createDependencyA = () => new DependencyA(memberValue); + + @container + class Container { + @component(createDependencyA, DependencyA) + public readonly dependencyA: DependencyA; + } + + // Act + const { dependencyA } = new Container(); + + // Assert + expect(dependencyA.member).to.be.equal(memberValue); + }); + + it("creates different components using factories and their naming classes", () => { + // Arrange + class DependencyA { + public constructor( + public readonly memberA: string, + ) { } + } + class DependencyB { + public constructor( + public readonly memberB: string, + ) { } + } + const memberValueA = "memberValueA"; + const memberValueB = "memberValueB"; + const createDependencyA = () => new DependencyA(memberValueA); + const createDependencyB = () => new DependencyB(memberValueB); + + @container + class Container { + @component(createDependencyA, DependencyA) + public readonly dependencyA: DependencyA; + + @component(createDependencyB, DependencyB) + public readonly dependencyB: DependencyB; + } + + // Act + const { dependencyA, dependencyB } = new Container(); + + // Assert + expect(dependencyA.memberA).to.be.equal(memberValueA); + expect(dependencyB.memberB).to.be.equal(memberValueB); + }); + + it("passes the container after creating getters to factories", () => { + // Arrange + class DependencyA { + public constructor( + public readonly memberA: string, + ) { } + } + class DependencyB { + public constructor( + public readonly referenceA: DependencyA, + public readonly valueC: string, + ) { } + } + const memberValueA = "memberValueA"; + const createDependencyA = () => new DependencyA(memberValueA); + const createDependencyB = (instance: Container) => new DependencyB(dependencyA, instance.valueC); + + @container + class Container { + @component(createDependencyA, DependencyA) + public readonly dependencyA: DependencyA; + + @component(createDependencyB, DependencyB) + public readonly dependencyB: DependencyB; + + public readonly valueC: string; + } + + // Act + const { dependencyA, dependencyB } = new Container(); + + // Assert + expect(dependencyA.memberA).to.be.equal(memberValueA); + expect(dependencyB.referenceA).to.be.equal(dependencyA); + }); +}); diff --git a/src/Container.ts b/src/Container.ts new file mode 100644 index 0000000..84af3d8 --- /dev/null +++ b/src/Container.ts @@ -0,0 +1,70 @@ +import { IClassWithArgs, IComponentListing } from "./Component"; + +/** + * Creates a getter method for a lazily computed instance. + * + * @param resolve Resolves the value of the instance. + * @returns A getter method for a lazilyi computed instance. + */ +const createLazyInstance = (resolve: () => any) => { + let instance: TInstance | undefined; + + return () => { + if (instance === undefined) { + instance = resolve(); + } + + return instance; + }; +}; + +/** + * Resolves the value of a lazily instantiated component. + * + * @param parentContainerInstance Parent container creating this component. + * + */ +const resolveComponent = (parentContainerInstance: any, listing: IComponentListing): any => { + const { componentFunction } = listing; + const componentInstance = new (componentFunction as IClassWithArgs)(parentContainerInstance); + + const dependencies = componentFunction.prototype.__dependencies__; + if (dependencies !== undefined) { + for (const dependency of dependencies) { + Object.defineProperty(componentInstance, dependency.memberName, { + configurable: true, + get: createLazyInstance(() => + parentContainerInstance[parentContainerInstance.__listings__[dependency.dependencyName].memberName]), + }); + } + } + + return componentInstance; +}; + +/** + * Creates a class that creates getters to resolve its components. + */ +export const container = (containerClass: { new(...args: any[]): any }): any => { + const createdComponents: any = {}; + + return class extends containerClass { + public constructor(...args: any[]) { + super(...args); + + const listings = containerClass.prototype.__listings__; + for (const listingName in listings) { + if (!{}.hasOwnProperty.call(listings, listingName)) { + continue; + } + + const listing = listings[listingName]; + + Object.defineProperty(this, listing.memberName, { + configurable: true, + get: createLazyInstance(() => resolveComponent(this, listing)), + }); + } + } + }; +}; diff --git a/src/Dependency.ts b/src/Dependency.ts new file mode 100644 index 0000000..6f25937 --- /dev/null +++ b/src/Dependency.ts @@ -0,0 +1,37 @@ +import { getFunctionName } from "./Reading"; + +/** + * Describes a dependency of a class. + */ +export interface IDependencyListing { + /** + * Class or function name of the dependency. + */ + dependencyName: string; + + /** + * Member property name the instance will be stored under. + */ + memberName: string; +} + +/** + * Adds a dependency to a component class. + * + * @param componentFunction Class or function creates the component, unique string name thereof. + */ +export const dependency = (dependencyName: string | Function): any => { + if (typeof dependencyName === "function") { + dependencyName = getFunctionName(dependencyName); + } + + return (dependingClass: any, memberName: string) => { + const listing = { dependencyName, memberName }; + + if (dependingClass.__dependencies__) { + dependingClass.__dependencies__.push(listing); + } else { + dependingClass.__dependencies__ = [listing]; + } + }; +}; diff --git a/src/Reading.test.ts b/src/Reading.test.ts new file mode 100644 index 0000000..195b456 --- /dev/null +++ b/src/Reading.test.ts @@ -0,0 +1,51 @@ +import { expect } from "chai"; + +import { getFunctionName } from "./Reading"; + +describe("Reading", () => { + describe("getFunctionName", () => { + it("returns the name of a function when there is a .name", () => { + // Arrange + const name = "test"; + const object = { name }; + + // Act + const result = getFunctionName(object); + + // Assert + expect(result).to.be.equal(name); + }); + + it("returns the name of a function when there is no .name and .toString results in a function", () => { + // Arrange + const name = "test"; + const object = { + toString() { + return `function ${name}() { /* ... */ }`; + }, + }; + + // Act + const result = getFunctionName(object as any); + + // Assert + expect(result).to.be.equal(name); + }); + + it("returns the name of a function when there is no .name and .toString results in a class", () => { + // Arrange + const name = "test"; + const object = { + toString() { + return `class ${name} { /* ... */ }`; + }, + }; + + // Act + const result = getFunctionName(object as any); + + // Assert + expect(result).to.be.equal(name); + }); + }); +}); diff --git a/src/Reading.ts b/src/Reading.ts new file mode 100644 index 0000000..74e3eab --- /dev/null +++ b/src/Reading.ts @@ -0,0 +1,28 @@ +/** + * Object with a name. + */ +export interface IObjectWithName { + /** + * Name of the object. + */ + name: string; +} + +/** + * Gets the friendly name of a class or function. + * + * @param method Class or function which may have a name. + * @returns Friendly name of the class or function. + */ +export const getFunctionName = (method: IObjectWithName | Function): string => { + if ((method as IObjectWithName).name !== undefined) { + return (method as IObjectWithName).name; + } + + const stringified = method.toString(); + const typeDescriptorMatch = stringified.match(/class|function/)!; + const indexOfNameSpace = typeDescriptorMatch.index! + typeDescriptorMatch[0].length; + const indexOfNameAfterSpace = stringified.search(/\(|\{/); + + return stringified.substring(indexOfNameSpace, indexOfNameAfterSpace).trim(); +}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..10399c5 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +export { component } from "./Component"; +export { container } from "./Container"; +export { dependency } from "./Dependency"; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9d9ee77 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "declaration": true, + "experimentalDecorators": true, + "jsx": "react", + "lib": ["dom", "es2015.collection", "es2015.promise", "es5"], + "module": "amd", + "moduleResolution": "node", + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noFallthroughCasesInSwitch": true, + "pretty": true, + "strictNullChecks": true, + "target": "es5" + }, + "exclude": [ + "dist", + "node_modules" + ], + "include": [ + "./src/**/*.ts", + "./src/**/*.tsx" + ] +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..dad2b06 --- /dev/null +++ b/tslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./node_modules/shenanigans-manager/setup/tslint.json", + "linterOptions": { + "exclude": [ + "./node_modules/**/*" + ] + }, + "rules": { + "no-any": false, + "no-non-null-assertion": false, + "no-parameter-properties": false, + "no-unsafe-any": false, + "ban-types": false, + "max-classes-per-file": [true, "exclude-class-expressions"] + } +}