aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--Makefile9
-rwxr-xr-xbin/dev-server19
-rwxr-xr-xbin/get-books (renamed from bin/get-data)0
-rwxr-xr-xbin/view2
-rw-r--r--flake.nix5
-rw-r--r--public/index.html2
-rw-r--r--public/main.css11
-rw-r--r--public/main.js19
-rw-r--r--src/lib/rx.ts398
-rw-r--r--src/main.ts48
-rw-r--r--tsconfig.json13
12 files changed, 496 insertions, 32 deletions
diff --git a/.gitignore b/.gitignore
index e34fb35..6bb7df2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/bin/view b/bin/view
index 21541c1..4fd4efc 100755
--- a/bin/view
+++ b/bin/view
@@ -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"
diff --git a/flake.nix b/flake.nix
index d2e3a07..48ae045 100644
--- a/flake.nix
+++ b/flake.nix
@@ -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/**/*"]
+}