├── docs
├── CNAME
├── _config.yml
├── examples
│ ├── observable.html
│ ├── templates.html
│ ├── basic.html
│ └── binding.html
└── index.md
├── .gitignore
├── src
├── index.ts
├── util
│ ├── html.ts
│ ├── html.test.ts
│ ├── resolvable.test.ts
│ ├── resolvable.ts
│ └── dom-wrapper.ts
├── utilities.ts
├── utilities.test.ts
├── compose.ts
├── render
│ ├── string-render.ts
│ ├── render.ts
│ └── dom-render.ts
├── elements.test.ts
├── elements.ts
└── component.ts
├── tsconfig.json
├── tsconfig.test.json
├── .github
└── workflows
│ └── npm.yml
├── Makefile
├── webpack.config.js
├── package.json
├── README.md
└── LICENSE
/docs/CNAME:
--------------------------------------------------------------------------------
1 | declarativ.js.org
2 |
--------------------------------------------------------------------------------
/docs/_config.yml:
--------------------------------------------------------------------------------
1 | theme: jekyll-theme-minimal
2 | show_downloads: "true"
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | .vscode/
3 | node_modules/
4 | dist/
5 | package-install.lock
6 | pnpm-lock.yaml
7 | .DS_Store
8 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { compose, wrapCompose } from './compose';
2 | export { observe } from './util/resolvable';
3 | export * as el from './elements';
4 | export * as util from './utilities';
5 |
--------------------------------------------------------------------------------
/src/util/html.ts:
--------------------------------------------------------------------------------
1 |
2 | export function escapeHtml(str: string) : string {
3 | return str
4 | .replace(/&/g, "&")
5 | .replace(//g, ">")
7 | .replace(/"/g, """)
8 | .replace(/'/g, "'");
9 | }
10 |
--------------------------------------------------------------------------------
/src/util/html.test.ts:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import { escapeHtml } from './html';
3 | import { DataResolvable, PendingTasks } from './resolvable';
4 |
5 | import * as el from '../elements';
6 | import * as util from '../utilities';
7 |
8 | describe("util/html.ts", () => {
9 | it("should escape HTML chars", () => {
10 | let htmlStr = "";
11 | expect(escapeHtml(htmlStr)).to.not.equal(htmlStr);
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/src/utilities.ts:
--------------------------------------------------------------------------------
1 | import { node, Component, ResolvableNode } from './component';
2 | import { ResolvableValue, resolve } from "./util/resolvable";
3 |
4 | export function html(str: string) : Component {
5 | return new Component(() => str);
6 | }
7 |
8 | export function forEach(items: ResolvableValue, ...components: ResolvableNode[]) : Component {
9 | return node(resolve(items).then(array => array.map((item) => node(components)?.bind(item))))!!;
10 | }
11 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "./dist/",
4 | "sourceMap": true,
5 | "declaration": true,
6 | "noImplicitAny": true,
7 | "module": "es6",
8 | "target": "es5",
9 | "lib": ["DOM", "ES2015", "ES2016.Array.Include", "ES2019.Array"],
10 | "allowJs": false,
11 | "allowSyntheticDefaultImports": true,
12 | "noImplicitThis": true,
13 | "strictNullChecks": true,
14 | "typeRoots": [
15 | "src/@types",
16 | "node_modules/@types"
17 | ]
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/utilities.test.ts:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 |
3 | import * as el from './elements';
4 | import * as util from './utilities';
5 |
6 | describe("utilities.ts", () => {
7 | it("iterates over arrays with forEach()", async function() {
8 | expect(
9 | await el.p(
10 | util.forEach(
11 | Promise.resolve(["a", "b", "c"]),
12 | el.span((str: any) => str)
13 | )
14 | ).renderString()
15 | ).to.be.equal("abc
");
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/tsconfig.test.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "./dist/",
4 | "sourceMap": true,
5 | "declaration": false,
6 | "inlineSourceMap": true,
7 | "noImplicitAny": true,
8 | "module": "commonjs",
9 | "target": "es5",
10 | "lib": ["DOM", "ES2015", "ES2016.Array.Include", "ES2019.Array"],
11 | "allowJs": false,
12 | "allowSyntheticDefaultImports": true,
13 | "noImplicitThis": true,
14 | "strictNullChecks": true,
15 | "typeRoots": [
16 | "src/@types",
17 | "node_modules/@types"
18 | ]
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/.github/workflows/npm.yml:
--------------------------------------------------------------------------------
1 | name: NodeJS Package
2 |
3 | on:
4 | push:
5 | paths:
6 | - 'src/*'
7 | - '*.js'
8 | - '*.json'
9 | pull_request:
10 | paths:
11 | - 'src/*'
12 | - '*.js'
13 | - '*.json'
14 |
15 | jobs:
16 | test:
17 | name: "Run tests"
18 | runs-on: ubuntu-latest
19 | strategy:
20 | matrix:
21 | node: [8, 10]
22 | steps:
23 | - uses: actions/checkout@master
24 | - uses: actions/setup-node@v1
25 | with:
26 | node-version: ${{ matrix.node }}
27 | - name: Install
28 | run: npm install
29 | - name: Test
30 | run: npm test
31 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: all install build-dev build test serve clean
2 |
3 | NPM := pnpm
4 | ifeq (, $(shell which pnpm))
5 | NPM = npm
6 | endif
7 |
8 | all: test build-dev
9 |
10 | install: package-install.lock
11 |
12 | package-install.lock: package.json
13 | ${NPM} install
14 | touch package-install.lock
15 |
16 | build-dev: install
17 | webpack-cli --mode=development --config webpack.config.js
18 |
19 | build: install
20 | webpack-cli --mode=production --config webpack.config.js
21 |
22 | test: install
23 | ${NPM} test
24 |
25 | serve: install build-dev
26 | http-server
27 |
28 | clean:
29 | rm -rf dist/
30 | rm -rf node_modules/
31 | rm -f package-install.lock
32 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const DeclarationBundlerPlugin = require('bundle-dts-webpack-plugin');
3 | const nodeExternals = require('webpack-node-externals');
4 | const pkg = require('./package.json');
5 |
6 | module.exports = (env, argv) => [
7 | { name: "declarativ.min.js", target: "umd", bundleDependencies: true },
8 | { name: "index.js", target: "commonjs2" }
9 | ].map(opts => ({
10 | entry: './src/index.ts',
11 | output: {
12 | path: path.resolve(__dirname, 'dist'),
13 | filename: opts.name,
14 | library: 'declarativ',
15 | libraryTarget: opts.target
16 | },
17 | externals: opts.bundleDependencies ? [] : [nodeExternals()],
18 | module: {
19 | rules: [
20 | {
21 | test: /\.tsx?$/,
22 | include: path.resolve(__dirname, 'src'),
23 | loader: 'ts-loader'
24 | }
25 | ]
26 | },
27 | resolve: {
28 | extensions: [ '.tsx', '.ts', '.js' ]
29 | },
30 | plugins: [
31 | new DeclarationBundlerPlugin({
32 | moduleName: "'declarativ'",
33 | out: "./types.d.ts"
34 | })
35 | ]
36 | }));
37 |
--------------------------------------------------------------------------------
/src/compose.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Creates a simple declarative component-based data binding
3 | * functionality for small HTML documents.
4 | *
5 | * @module compose/index
6 | */
7 |
8 | import { Component, ResolvableNode } from './component';
9 |
10 | /**
11 | * Wrap a component in a composition function.
12 | *
13 | * @param {Component} component - the component to wrap
14 | * @returns {function(...[ResolvableNode]): Component} - the composed function
15 | */
16 | export function wrapCompose(component: Component) : (children: ResolvableNode[]) => Component {
17 | return function(...children: ResolvableNode[]) {
18 | return component.withChildrenArray(children);
19 | };
20 | }
21 |
22 | /**
23 | * Shorthand for creating a new Component instance.
24 | *
25 | * @param {function(string, any): string} template - the HTML function to template with
26 | * @return {function(...[ResolvableNode]): Component} - a Component function
27 | */
28 | export function compose(template: (inner: string, data: any) => string) : (...children: ResolvableNode[]) => Component {
29 | return function(...children: ResolvableNode[]) {
30 | return new Component(template, children);
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "declarativ",
3 | "version": "0.1.7",
4 | "description": "This is definitely not JSX.",
5 | "module": "dist/index.js",
6 | "main": "dist/declarativ.min.js",
7 | "types": "dist/types.d.ts",
8 | "files": [
9 | "README.md",
10 | "LICENSE",
11 | "package.json",
12 | "dist/*"
13 | ],
14 | "scripts": {
15 | "test": "env TS_NODE_PROJECT=\"tsconfig.test.json\" mocha -r ts-node/register src/*.test.ts src/**/*.test.ts"
16 | },
17 | "repository": {
18 | "type": "git",
19 | "url": "git+https://github.com/fennifith/declarativ.git"
20 | },
21 | "author": "James Fenn (https://jfenn.me/)",
22 | "license": "MPL-2.0",
23 | "bugs": {
24 | "url": "https://github.com/fennifith/declarativ/issues"
25 | },
26 | "homepage": "https://declarativ.js.org/",
27 | "dependencies": {
28 | "ts-polyfill": "^3.8.2"
29 | },
30 | "devDependencies": {
31 | "@types/chai": "^4.2.11",
32 | "@types/mocha": "^7.0.2",
33 | "bundle-dts-webpack-plugin": "^1.1.0",
34 | "chai": "^4.2.0",
35 | "mocha": "^6.2.0",
36 | "ts-loader": "^7.0.5",
37 | "ts-mocha": "^7.0.0",
38 | "ts-node": "^8.10.2",
39 | "typescript": "^3.9.5",
40 | "webpack": "^4.43.0",
41 | "webpack-cli": "^3.3.7",
42 | "webpack-node-externals": "^1.7.2"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/docs/examples/observable.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Basic - Declarativ Examples
5 |
6 |
7 |
8 |
9 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/docs/examples/templates.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Basic - Declarativ Examples
5 |
6 |
7 |
8 |
9 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/src/render/string-render.ts:
--------------------------------------------------------------------------------
1 | import { Component } from '../component';
2 | import { Render, RenderOpts } from './render';
3 | import { forEachAsync } from '../util/resolvable';
4 | import { element } from '../util/dom-wrapper';
5 |
6 | export class StringRender extends Render {
7 |
8 | constructor(opts?: RenderOpts) {
9 | super(opts);
10 | }
11 |
12 | /**
13 | * Perform a recursive render... thing...
14 | *
15 | * @param {*} parentData - The current data object to bind to components.
16 | * @param {HTMLElement|ElementImpl?} tempElement - The element/object that components should replace.
17 | * @param {Component} component - The component to start the render at.
18 | * @return {String} The rendered string.
19 | */
20 | async doRender(data: any, tempElement: string | null, component: Component) : Promise {
21 | // create basic html
22 | let innerHtml = "";
23 | await forEachAsync(await component.resolveChildren(data), async (child) => {
24 | if (typeof child === "string")
25 | innerHtml += child;
26 | else innerHtml += await this.render(data, null, child);
27 | });
28 |
29 | this.opts.debugLogger?.(" Resolved child elements:", innerHtml);
30 |
31 | // TODO: support attribute values / this.tasks.call() on string returns
32 |
33 | // render HTML structure
34 | let str = component.template(innerHtml, data);
35 | let strImpl = element(str);
36 | await component.tasks.call(strImpl, data);
37 |
38 | if (strImpl)
39 | return strImpl.get();
40 | else throw "Error occurred: null string";
41 | }
42 |
43 | }
44 |
--------------------------------------------------------------------------------
/docs/examples/basic.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Basic - Declarativ Examples
5 |
6 |
7 |
8 |
9 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/src/util/resolvable.test.ts:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import { DataResolvable, PendingTasks } from './resolvable';
3 |
4 | describe("util/resolvable.ts", () => {
5 | describe("DataResolvable", () => {
6 | it("should resolve promises", async function() {
7 | let data = new DataResolvable(Promise.resolve(3));
8 | expect(await data.resolve()).to.equal(3);
9 | });
10 |
11 | it("should resolve functions", async function() {
12 | let data = new DataResolvable(() => 15);
13 | expect(await data.resolve()).to.equal(15);
14 | });
15 |
16 | it("should resolve raw values", async function() {
17 | let data = new DataResolvable("hello!");
18 | expect(await data.resolve()).to.equal("hello!");
19 | });
20 |
21 | it("should pass resolved arguments through to functions", async function() {
22 | let data = new DataResolvable((d: any) => d);
23 | expect(await data.resolve(250)).to.equal(250);
24 | });
25 | });
26 |
27 | describe("PendingTasks", () => {
28 | let functions = [
29 | (): any => null,
30 | (): any => null,
31 | (): any => null,
32 | (): any => null,
33 | (): any => null,
34 | ];
35 |
36 | let tasks = new PendingTasks([]);
37 | functions.forEach((task) => tasks.push(task));
38 |
39 | it("should have the correct length attribute", () => {
40 | expect(tasks.length).to.equal(functions.length);
41 | });
42 |
43 | it("can be constructed from an array", () => {
44 | let newTasks = new PendingTasks(functions);
45 | expect(newTasks).to.have.property('length');
46 | expect(newTasks.length).to.be.equal(functions.length);
47 | });
48 |
49 | it("resolves all tasks when called", async function() {
50 | let i = 0;
51 | let pending = new PendingTasks([() => i = 1]);
52 | await pending.call();
53 |
54 | expect(i).to.equal(1);
55 | });
56 |
57 | it("passes arguments to called functions", async function() {
58 | let val = 0;
59 | let pending = new PendingTasks([(arg) => val = arg]);
60 | await pending.call(20);
61 |
62 | expect(val).to.equal(20);
63 | });
64 | });
65 | });
66 |
--------------------------------------------------------------------------------
/docs/examples/binding.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Basic - Declarativ Examples
5 |
6 |
7 |
8 |
9 |
64 |
65 |
66 |
--------------------------------------------------------------------------------
/src/render/render.ts:
--------------------------------------------------------------------------------
1 | import { Component, Element } from "../component";
2 | import { DataObservable, resolve } from '../util/resolvable';
3 |
4 | export interface RenderOpts {
5 | debugLogger?: (...data: any[]) => void
6 | strict?: boolean
7 | }
8 |
9 | export abstract class Render {
10 |
11 | opts: RenderOpts
12 |
13 | constructor(opts?: RenderOpts) {
14 | this.opts = opts || {}
15 | }
16 |
17 | /**
18 | * Base/wrapped render function (calls doRender with conditions)
19 | *
20 | * @param {*} parentData
21 | * @param {*} tempElement
22 | * @param {*} component
23 | */
24 | async render(parentData: any, tempElement: E | null, component: Component) : Promise {
25 | try {
26 | this.opts.debugLogger?.(`Rendering component ${component.template}`)
27 |
28 | // render loading state first... (if present)
29 | if (component.loadingState) {
30 | tempElement = await this.render(null, tempElement, await resolve(component.loadingState));
31 | }
32 |
33 | // resolve critical data first
34 | let data = component.data ? await resolve(component.data, parentData) : parentData;
35 | this.opts.debugLogger?.(' Resolved data:', data);
36 |
37 | // perform actual render
38 | let element = await this.doRender(data, tempElement, component);
39 | this.opts.debugLogger?.(' Finished render:', element);
40 |
41 | if (component.data instanceof DataObservable) {
42 | // subscribe to observable changes
43 | component.data.subscribe(async (newData) => {
44 | element = await this.doRender(newData, element, component);
45 | });
46 | }
47 |
48 | return element;
49 | } catch (e) {
50 | // fallback component (if present)
51 | if (component.fallbackState)
52 | return await this.render(e, tempElement, component.fallbackState);
53 | else throw e;
54 | }
55 | }
56 |
57 | /**
58 | * Perform a recursive render... thing...
59 | *
60 | * @param {*} parentData - The current data object to bind to components.
61 | * @param {HTMLElement|ElementImpl?} tempElement - The element/object that components should replace.
62 | * @param {Component} component - The component to start the render at.
63 | * @return {*} The rendered item.
64 | */
65 | abstract async doRender(parentData: any, tempElement: E | null, component: Component) : Promise
66 |
67 | }
68 |
--------------------------------------------------------------------------------
/src/elements.test.ts:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import { escapeHtml } from './util/html';
3 | import { DataResolvable, PendingTasks } from './util/resolvable';
4 |
5 | import * as el from './elements';
6 | import * as util from './utilities';
7 |
8 | describe("elements.ts", () => {
9 | describe("Basic Rendering", () => {
10 | it("should render an empty tag", async function() {
11 | expect(await el.p().renderString()).to.be.equal("");
12 | });
13 |
14 | it("should render text inside a tag", async function() {
15 | expect(await el.p("Hello!").renderString()).to.be.equal("Hello!
");
16 | });
17 |
18 | it("should render elements inside a tag", async function() {
19 | expect(
20 | await el.p(
21 | el.span(),
22 | el.a()
23 | ).renderString()
24 | ).to.be.equal("
")
25 | });
26 |
27 | it("should render functions inside a tag", async function() {
28 | expect(
29 | await el.p(() => "Hello!").renderString()
30 | ).to.be.equal("Hello!
");
31 | });
32 |
33 | it("should render promises inside a tag", async function() {
34 | expect(
35 | await el.p(Promise.resolve("Hello!")).renderString()
36 | ).to.be.equal("Hello!
");
37 | });
38 |
39 | it("should render tag attributes", async function() {
40 | expect(
41 | await el.p().attr("id", "render").renderString()
42 | ).to.be.equal('')
43 | });
44 | });
45 |
46 | describe("Data States", () => {
47 | it("should fallback to error components", async function() {
48 | expect(
49 | await el.p(
50 | () => { throw "aaa"; }
51 | ).whenError(
52 | el.span("Oh no.")
53 | ).renderString()
54 | ).to.be.equal("Oh no.");
55 | });
56 | });
57 |
58 | describe("Data Binding", () => {
59 | it("should bind data to child functions", async function() {
60 | expect(
61 | await el.p(
62 | (data: any) => data
63 | ).bind("Hello!").renderString()
64 | ).to.equal("Hello!
");
65 | });
66 |
67 | it("should bind data to child elements", async function() {
68 | expect(
69 | await el.p(
70 | el.span(
71 | el.span(
72 | (data: any) => data
73 | )
74 | )
75 | ).bind("Hello!").renderString()
76 | ).to.equal("Hello!
");
77 | });
78 | });
79 | });
80 |
--------------------------------------------------------------------------------
/src/render/dom-render.ts:
--------------------------------------------------------------------------------
1 | import { Render, RenderOpts } from './render';
2 | import { forEachAsync } from '../util/resolvable';
3 | import * as dom from '../util/dom-wrapper';
4 | import { Component } from '../component';
5 |
6 | let nodeCount = 0;
7 |
8 | export class DOMRender extends Render {
9 |
10 | constructor(opts?: RenderOpts) {
11 | super(opts);
12 | }
13 |
14 | /**
15 | * Perform a recursive render... thing...
16 | *
17 | * @param {*} data - The current data object to bind to components.
18 | * @param {Node|ElementImpl?} tempElement - The element/object that components should replace.
19 | * @param {Component} component - The component to start the render at.
20 | * @return {*} The rendered item.
21 | */
22 | async doRender(data: any, tempElement: Node | null, component: Component) : Promise {
23 | // create basic html
24 | let innerHtml = "";
25 | let components: {[id: string]: Component} = {};
26 | await forEachAsync(await component.resolveChildren(data), async (child, index) => {
27 | if (typeof child === "string") {
28 | innerHtml += child;
29 | } else {
30 | let id = `decl-${nodeCount++}-${index}`;
31 | innerHtml += ``;
32 | components[id] = child;
33 | }
34 | });
35 |
36 | this.opts.debugLogger?.(" Resolved child elements:", innerHtml);
37 |
38 | // render HTML structure
39 | let elements = dom.createHtml(component.template(innerHtml, data));
40 | let elementImpl = dom.element(elements[0]);
41 |
42 | // call immediate tasks (attributes, etc.)
43 | await component.tasks.call(elementImpl, data);
44 |
45 | let tempElementImpl = dom.element(tempElement);
46 | if (tempElementImpl) { // replace tempElement on dom
47 | tempElementImpl.replaceWith(elements[0]);
48 | if (!elements[0].parentNode)
49 | throw "No parent node on element " + elements[0];
50 |
51 | for (let i = 1; i < elements.length; i++) {
52 | // insert any additional nodes into the DOM (in case the template is weird)
53 | dom.element(elements[0].parentNode)?.insertAfter(elements[i], elements[i-1]);
54 | }
55 | }
56 |
57 | // render / await child nodes
58 | await Promise.all(Object.keys(components).map(async (id) => {
59 | let temp = document.querySelector(`#${id}`);
60 | if (!temp || !(temp instanceof HTMLElement))
61 | throw `couldn't find child ${id}`;
62 |
63 | let result = await this.render(data, temp, components[id]);
64 | for (let i = 0; i < elements.length; i++) {
65 | // account for stray elements in template (see large comment block below)
66 | let element = elements[i];
67 | if (element instanceof HTMLElement && element.id === id)
68 | elements[i] = result;
69 | }
70 | }));
71 |
72 | await Promise.all(elements.map((element) => component.tasksAfter.call(element, data)));
73 |
74 | // Only returning the first element makes it possible for some to
75 | // potentially "leak" from the tree. As such, multi-element nodes should
76 | // be discouraged (for now...)
77 | //
78 | // This could be worked around in the future by writing a `dom-wrapper`
79 | // implementation for arrays that modifies each element in the array;
80 | // `element.replace(...)` would replace the first element and remove all
81 | // others, etc.
82 | return elements[0];
83 | }
84 |
85 | }
86 |
--------------------------------------------------------------------------------
/src/util/resolvable.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Useful data-handling classes and functions.
3 | *
4 | * @module util/resolvable
5 | */
6 |
7 | export type ResolvableValue = T | ((data: any) => ResolvableValue) | Promise
8 |
9 | /**
10 | * A wrapper class for data-based promises and/or arbitrary
11 | * values that enter a component.
12 | *
13 | * @property {Object|function(Object): Object|Promise