diff options
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | Makefile | 9 | ||||
-rwxr-xr-x | bin/dev-server | 19 | ||||
-rwxr-xr-x | bin/get-books (renamed from bin/get-data) | 0 | ||||
-rwxr-xr-x | bin/view | 2 | ||||
-rw-r--r-- | flake.nix | 5 | ||||
-rw-r--r-- | public/index.html | 2 | ||||
-rw-r--r-- | public/main.css | 11 | ||||
-rw-r--r-- | public/main.js | 19 | ||||
-rw-r--r-- | src/lib/rx.ts | 398 | ||||
-rw-r--r-- | src/main.ts | 48 | ||||
-rw-r--r-- | tsconfig.json | 13 |
12 files changed, 496 insertions, 32 deletions
@@ -1 +1 @@ -public/data.js +public/*.js diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..593752d --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +build: + @esbuild \ + --bundle src/main.ts \ + --minify \ + --target=es2017 \ + --outdir=public + +clean: + @rm -f public/main.js diff --git a/bin/dev-server b/bin/dev-server index 4136091..82a407c 100755 --- a/bin/dev-server +++ b/bin/dev-server @@ -9,13 +9,18 @@ else exit 1 fi -# Watch +# Watch books -clear -echo "Open your browser at file://$PWD/public/index.html" -echo - -BUILD_CMD="./bin/get-data $BOOK_DIR > public/data.js && echo public/data.js updated." +BUILD_BOOKS_CMD="./bin/get-books $BOOK_DIR > public/books.js && echo public/books.js updated." watchexec \ --watch "$BOOK_DIR" \ - -- "$BUILD_CMD" + -- "$BUILD_BOOKS_CMD" & + +# Watch TypeScript + +CHECK="echo Checking TypeScript… && tsc --checkJs" +BUILD="esbuild --bundle src/main.ts --target=es2017 --outdir=public" +watchexec \ + --clear \ + --watch src \ + -- "$CHECK && $BUILD" diff --git a/bin/get-data b/bin/get-books index 0fbbd2f..0fbbd2f 100755 --- a/bin/get-data +++ b/bin/get-books @@ -12,5 +12,5 @@ fi TMP_DIR=$(mktemp --directory) cp public/* "$TMP_DIR" -bin/get-data "$BOOK_DIR" > "$TMP_DIR/data.js" +bin/get-books "$BOOK_DIR" > "$TMP_DIR/books.js" eval "$BROWSER $TMP_DIR/index.html" @@ -11,7 +11,10 @@ in with pkgs; { devShell = mkShell { buildInputs = [ - psmisc + esbuild + nodePackages.typescript + psmisc # fuser + python3 watchexec ]; }; diff --git a/public/index.html b/public/index.html index 13136da..bbef5ae 100644 --- a/public/index.html +++ b/public/index.html @@ -2,5 +2,5 @@ <meta charset="UTF-8"> <link rel="stylesheet" href="main.css"> <body></body> -<script src="data.js"></script> +<script src="books.js"></script> <script src="main.js"></script> diff --git a/public/main.css b/public/main.css index 711aaf2..7e7f297 100644 --- a/public/main.css +++ b/public/main.css @@ -1,8 +1,15 @@ body { - margin: 2rem; + margin: 0; + display: flex; } -.g-Books { +.g-Aside { + padding: 1rem; + background-color: #333; +} + +.g-Main { + padding: 1rem; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); grid-gap: 1rem; diff --git a/public/main.js b/public/main.js deleted file mode 100644 index 29de235..0000000 --- a/public/main.js +++ /dev/null @@ -1,19 +0,0 @@ -const sortedBooks = books.sort((a, b) => - a.authorsSort == b.authorsSort - ? a.date > b.date - : a.authorsSort > b.authorsSort) - -const view = h('div', - { className: 'g-Books' }, - ...sortedBooks.map(book => h('img', { className: 'g-Book', src: book.cover }))) - -document.body.appendChild(view) - -// Helpers - -function h(tagName, attrs, ...children) { - let elem = document.createElement(tagName) - elem = Object.assign(elem, attrs) - for (const child of children) elem.append(child) - return elem -} diff --git a/src/lib/rx.ts b/src/lib/rx.ts new file mode 100644 index 0000000..dbdd3ad --- /dev/null +++ b/src/lib/rx.ts @@ -0,0 +1,398 @@ +// Html + +export interface Html { + type: 'Tag' | 'WithVar' +} + +export interface Tag extends Html { + type: 'Tag' + tagName: string + attributes: Attributes + children?: Array<Child> + onmount?: (element: Element) => void + onunmount?: (element: Element) => void +} + +export interface WithVar<A> extends Html { + type: 'WithVar' + init: A + getChildren: (v: Var<A>, update: (f: (value: A) => A) => void) => Child +} + +interface Attributes { + [key: string]: Rx<AttributeValue> | AttributeValue +} + +export type AttributeValue + = string + | number + | boolean + | ((event: Event) => void) + | ((element: Element) => void) + +export type Child = false | undefined | string | number | Html | Rx<Child> | Array<Child> + +function isChild(x: any): x is Child { + return (typeof x === 'string' + || typeof x === 'number' + || isHtml(x) + || isRx(x) + || Array.isArray(x)) +} + +type ValueOrArray<T> = T | Array<ValueOrArray<T>> + +export function h( + tagName: string, + x?: Attributes | Child, + ...children: Array<Child> +): Tag { + if (x === undefined || x === false) { + return { + type: 'Tag', + tagName, + attributes: {} + } + } else if (isChild(x)) { + return { + type: 'Tag', + tagName, + attributes: {}, + children: [x, ...children], + } + } else { + let attributes = x as Attributes + let onmount, onunmount + if ('onmount' in attributes) { + onmount = attributes['onmount'] as (element: Element) => void + delete attributes['onmount'] + } + if ('onunmount' in attributes) { + onunmount = attributes['onunmount'] as (element: Element) => void + delete attributes['onunmount'] + } + return { + type: 'Tag', + tagName, + attributes, + children, + onmount, + onunmount + } + } +} + +export function withVar<A>(init: A, getChildren: (v: Var<A>, update: (f: (value: A) => A) => void) => Child): WithVar<A> { + return { + type: 'WithVar', + init, + getChildren + } +} + +// Rx + +export type RxAble<A> = Rx<A> | A + +export class Rx<A> { + map<B>(f: (value: A) => B): Rx<B> { + return new Map<A, B>(this, f) + } + + flatMap<B>(f: (value: A) => Rx<B>): Rx<B> { + return new FlatMap<A, B>(this, f) + } +} + +class Var<A> extends Rx<A> { + readonly type: 'Var' + readonly id: string + + constructor(id: string) { + super() + this.id = id + this.type = 'Var' + } +} + +export class Map<A, B> extends Rx<B> { + readonly type: 'Map' + readonly rx: Rx<A> + readonly f: (value: A) => B + + constructor(rx: Rx<A>, f: (value: A) => B) { + super() + this.type = 'Map' + this.rx = rx + this.f = f + } +} + +export class FlatMap<A, B> extends Rx<B> { + readonly type: 'FlatMap' + readonly rx: Rx<A> + readonly f: (value: A) => Rx<B> + + constructor(rx: Rx<A>, f: (value: A) => Rx<B>) { + super() + this.type = 'FlatMap' + this.rx = rx + this.f = f + } +} + +// Mount + +export function mount(child: Child): Cancelable { + const state = new State() + let appendRes = appendChild(state, document.body, child) + return appendRes.cancel +} + +interface StateEntry<A> { + value: A + subscribers: Array<(value: A) => void> +} + +class State { + readonly state: {[key: string]: StateEntry<any>} + varCounter: bigint + + constructor() { + this.state = {} + this.varCounter = BigInt(0) + } + + register<A>(initValue: A) { + const v = new Var(this.varCounter.toString()) + this.varCounter += BigInt(1) + this.state[v.id] = { + value: initValue, + subscribers: [] + } + return v + } + + unregister<A>(v: Var<A>) { + delete this.state[v.id] + } + + get<A>(v: Var<A>) { + return this.state[v.id].value + } + + update<A>(v: Var<A>, f: (value: A) => A) { + const value = f(this.state[v.id].value) + this.state[v.id].value = value + this.state[v.id].subscribers.forEach(notify => { + // Don’t notify if it has been removed from a precedent notifier + if (this.state[v.id].subscribers.indexOf(notify) !== -1) { + notify(value) + } + }) + } + + subscribe<A>(v: Var<A>, notify: (value: A) => void): Cancelable { + this.state[v.id].subscribers.push(notify) + return () => this.state[v.id].subscribers = this.state[v.id].subscribers.filter(n => n !== notify) + } +} + +// Cancelable + +type Cancelable = () => void + +const voidCancel = () => {} + +// Removable + +type Removable = () => void + +const voidRemove = () => {} + +// Rx run + +function rxRun<A>(state: State, rx: Rx<A>, effect: (value: A) => void): Cancelable { + if (isVar(rx)) { + const cancel = state.subscribe(rx, effect) + effect(state.get(rx)) + return cancel + } else if (isMap<A, any>(rx)) { + return rxRun(state, rx.rx, value => effect(rx.f(value))) + } else if (isFlatMap(rx)) { + let cancel1 = voidCancel + const cancel2 = rxRun(state, rx.rx, (value: A) => { + cancel1() + cancel1 = rxRun(state, rx.f(value), effect) + }) + return () => { + cancel2() + cancel1() + } + } else { + throw new Error(`Unrecognized rx: ${rx}`) + } +} + +function isRx<A>(x: any): x is Rx<A> { + return x !== undefined && x.type !== undefined && (x.type === "Var" || x.type === "Map" || x.type === "FlatMap") +} + +function isVar<A>(x: any): x is Var<A> { + return x.type === "Var" +} + +function isMap<A, B>(x: any): x is Map<A, B> { + return x.type === "Map" +} + +function isFlatMap<A, B>(x: any): x is FlatMap<A, B> { + return x.type === "FlatMap" +} + +// Append + +interface AppendResult { + cancel: Cancelable + remove: Removable + lastAdded?: Node +} + +function appendChild(state: State, element: Element, child: Child, lastAdded?: Node): AppendResult { + if (Array.isArray(child)) { + let cancels: Array<Cancelable> = [] + let removes: Array<Removable> = [] + child.forEach((o) => { + const appendResult = appendChild(state, element, o, lastAdded) + cancels.push(appendResult.cancel) + removes.push(appendResult.remove) + lastAdded = appendResult.lastAdded + }) + return { + cancel: () => cancels.forEach((o) => o()), + remove: () => removes.forEach((o) => o()), + lastAdded + } + } else if (typeof child == "string") { + const node = document.createTextNode(child) + appendNode(element, node, lastAdded) + return { + cancel: voidCancel, + remove: () => element.removeChild(node), + lastAdded: node + } + } else if (typeof child == "number") { + return appendChild(state, element, child.toString(), lastAdded) + } else if (isTag(child)) { + const { tagName, attributes, children, onmount, onunmount } = child + + const childElement = document.createElement(tagName) + const cancelAttributes = Object.entries(attributes).map(([key, value]) => { + if (isRx<AttributeValue>(value)) { + return rxRun(state, value, newValue => setAttribute(state, childElement, key, newValue)) + } else { + setAttribute(state, childElement, key, value) + } + }) + + const appendChildrenRes = appendChild(state, childElement, children) + + appendNode(element, childElement, lastAdded) + + if (onmount !== undefined) { + onmount(childElement) + } + + return { + cancel: () => { + cancelAttributes.forEach(cancel => cancel !== undefined ? cancel() : {}) + appendChildrenRes.cancel() + if (onunmount !== undefined) { + onunmount(childElement) + } + }, + remove: () => element.removeChild(childElement), + lastAdded: childElement, + } + } else if (isWithVar(child)) { + const { init, getChildren } = child + const v = state.register(init) + const children = getChildren(v, f => state.update(v, f)) + const appendRes = appendChild(state, element, children) + return { + cancel: () => { + appendRes.cancel() + state.unregister(v) + }, + remove: () => appendRes.remove(), + lastAdded: appendRes.lastAdded + } + } else if (isRx(child)) { + const rxBase = document.createTextNode("") + element.append(rxBase) + let appendRes: AppendResult = { + cancel: voidCancel, + remove: voidRemove, + lastAdded: rxBase + } + const cancelRx = rxRun(state, child, (value: Child) => { + appendRes.cancel() + appendRes.remove() + appendRes = appendChild(state, element, value, rxBase) + }) + return { + cancel: () => { + appendRes.cancel() + cancelRx() + }, + remove: appendRes.remove, + lastAdded: appendRes.lastAdded, + } + } else if (child === undefined || child === false) { + return { + cancel: voidCancel, + remove: voidRemove, + lastAdded + } + } else { + throw new Error(`Unrecognized child: ${child}`) + } +} + +function isHtml<A>(x: any): x is Html { + return isTag<A>(x) || isWithVar<A>(x) +} + +function isTag<A>(x: any): x is Tag { + return x !== undefined && x.type === "Tag" +} + +function isWithVar<A>(x: any): x is WithVar<A> { + return x !== undefined && x.type === "WithVar" +} + +function appendNode(base: Element, node: Node, lastAdded?: Node) { + if (lastAdded !== undefined) { + base.insertBefore(node, lastAdded.nextSibling) + } else { + base.append(node) + } +} + +function setAttribute(state: State, element: Element, key: string, attribute: AttributeValue) { + if (typeof attribute == "boolean") { + if (attribute) { + // @ts-ignore + element[key] = "true" + } + } else if (typeof attribute == "number") { + // @ts-ignore + element[key] = attribute.toString() + } else if (typeof attribute == "string") { + // @ts-ignore + element[key] = attribute + } else { + // @ts-ignore + element[key] = (event: Event) => attribute(event) + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..7033690 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,48 @@ +import { h, withVar, mount } from 'lib/rx' + +interface Book { + title: string + authors: Array<string> + authorsSort: string + genres: Array<string> + date: string + read: boolean + cover: string +} + +// @ts-ignore +const sortedBooks: Array<Book> = (books as Array<Book>).sort((a, b) => + a.authorsSort == b.authorsSort + ? a.date > b.date + : a.authorsSort > b.authorsSort) + +enum Filter { + All, + Read, + Unread +} + +const view = withVar(Filter.All, (filter, updateFilter) => [ + h('aside', + { className: 'g-Aside' }, + h('select', + { name: 'filter', + id: 'filter', + onchange: (event: Event) => updateFilter(_ => (event.target as HTMLSelectElement).value as any) + }, + h('option', { value: Filter.All }, 'Tous les livres'), + h('option', { value: Filter.Read }, 'Livres lus'), + h('option', { value: Filter.Unread }, 'Livres non lus') + ) + ), + h('main', + { className: 'g-Main' }, + filter.map(f => + sortedBooks + .filter(b => f == Filter.All || b.read == (f == Filter.Read)) + .map(book => h('img', { className: 'g-Book', src: book.cover })) + ) + ) +]) + +mount(view) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6c9e683 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "amd", + "target": "es2020", + "baseUrl": "src", + "outFile": "public/main.js", + "noImplicitAny": true, + "strictNullChecks": true, + "removeComments": true, + "preserveConstEnums": true + }, + "include": ["src/**/*"] +} |