web-util: utils for developing webapps

This commit is contained in:
Sebastian 2022-12-06 09:21:17 -03:00
parent 219e48f351
commit e382b02203
No known key found for this signature in database
GPG Key ID: BE4FF68352439FC1
21 changed files with 1189 additions and 0 deletions

0
packages/web-util/README Normal file
View File

View File

@ -0,0 +1,19 @@
#!/usr/bin/env node
/*
This file is part of GNU Taler
(C) 2022 Taler Systems SA
TALER is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
TALER is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { main } from '../lib/cli.cjs';
main();

103
packages/web-util/build.mjs Executable file
View File

@ -0,0 +1,103 @@
#!/usr/bin/env node
/*
This file is part of GNU Taler
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import esbuild from 'esbuild'
import path from "path"
import fs from "fs"
// eslint-disable-next-line no-undef
const BASE = process.cwd()
let GIT_ROOT = BASE
while (!fs.existsSync(path.join(GIT_ROOT, '.git')) && GIT_ROOT !== '/') {
GIT_ROOT = path.join(GIT_ROOT, '../')
}
if (GIT_ROOT === '/') {
// eslint-disable-next-line no-undef
console.log("not found")
// eslint-disable-next-line no-undef
process.exit(1);
}
const GIT_HASH = GIT_ROOT === '/' ? undefined : git_hash()
let _package = JSON.parse(fs.readFileSync(path.join(BASE, 'package.json')));
function git_hash() {
const rev = fs.readFileSync(path.join(GIT_ROOT, '.git', 'HEAD')).toString().trim().split(/.*[: ]/).slice(-1)[0];
if (rev.indexOf('/') === -1) {
return rev;
} else {
return fs.readFileSync(path.join(GIT_ROOT, '.git', rev)).toString().trim();
}
}
const buildConfigBase = {
outdir: "lib",
bundle: true,
minify: false,
target: [
'es6'
],
loader: {
'.key': 'text',
'.crt': 'text',
'.html': 'text',
},
sourcemap: true,
define: {
'__VERSION__': `"${_package.version}"`,
'__GIT_HASH__': `"${GIT_HASH}"`,
},
}
const buildConfigNode = {
...buildConfigBase,
entryPoints: ["src/index.node.ts", "src/cli.ts"],
outExtension: {
'.js': '.cjs'
},
format: 'cjs',
platform: 'node',
external: ["preact"],
};
const buildConfigBrowser = {
...buildConfigBase,
entryPoints: ["src/index.browser.ts", "src/live-reload.ts", 'src/stories.tsx'],
outExtension: {
'.js': '.mjs'
},
format: 'esm',
platform: 'browser',
external: ["preact", "@gnu-taler/taler-util", "jed"],
jsxFactory: 'h',
jsxFragment: 'Fragment',
};
[buildConfigNode, buildConfigBrowser].forEach((config) => {
esbuild
.build(config)
.catch((e) => {
// eslint-disable-next-line no-undef
console.log(e)
// eslint-disable-next-line no-undef
process.exit(1)
});
})

View File

@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -eu
org=localhost-ca
domain=localhost
rm -rf keys
mkdir keys
cd keys
openssl genpkey -algorithm RSA -out ca.key
openssl req -x509 -key ca.key -out ca.crt \
-subj "/CN=$org/O=$org"
openssl genpkey -algorithm RSA -out "$domain".key
openssl req -new -key "$domain".key -out "$domain".csr \
-subj "/CN=$domain/O=$org"
openssl x509 -req -in "$domain".csr -days 365 -out "$domain".crt \
-CA ca.crt -CAkey ca.key -CAcreateserial \
-extfile <(cat <<END
basicConstraints = CA:FALSE
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
subjectAltName = DNS:$domain
END
)
sudo cp ca.crt /usr/local/share/ca-certificates/testing.crt
sudo update-ca-certificates
echo '
## Chrome
1. go to chrome://settings/certificates
2. tab "authorities"
3. button "import"
4. choose "ca.crt"
5. trust for identify websites
## Firefox
1. go to about:preferences#privacy
2. button "view certificates"
3. button "import"
4. choose "ca.crt"
5. trust for identify websites
'
echo done!

View File

@ -0,0 +1,40 @@
{
"name": "@gnu-taler/web-util",
"version": "0.9.0",
"description": "Generic helper functionality for GNU Taler Web Apps",
"type": "module",
"types": "./lib/index.node.d.ts",
"main": "./dist/taler-web-cli.cjs",
"bin": {
"taler-wallet-cli": "./bin/taler-web-cli.cjs"
},
"author": "Sebastian Marchano",
"license": "AGPL-3.0-or-later",
"private": false,
"exports": {
"./lib/index.browser": "./lib/index.browser.mjs",
"./lib/index.node": "./lib/index.node.cjs"
},
"scripts": {
"prepare": "tsc",
"compile": "./build.mjs",
"clean": "rimraf dist lib tsconfig.tsbuildinfo",
"pretty": "prettier --write src"
},
"devDependencies": {
"@gnu-taler/taler-util": "workspace:*",
"@types/express": "^4.17.14",
"@types/node": "^18.11.9",
"@types/web": "^0.0.82",
"@types/ws": "^8.5.3",
"chokidar": "^3.5.3",
"esbuild": "^0.14.21",
"express": "^4.18.2",
"preact": "10.11.3",
"prettier": "^2.5.1",
"rimraf": "^3.0.2",
"tslib": "^2.4.0",
"typescript": "^4.8.4",
"ws": "7.4.5"
}
}

View File

@ -0,0 +1,59 @@
import {
clk, setGlobalLogLevelFromString
} from "@gnu-taler/taler-util";
import { serve } from "./serve.js";
export const walletCli = clk
.program("wallet", {
help: "Command line interface for the GNU Taler wallet.",
})
.maybeOption("log", ["-L", "--log"], clk.STRING, {
help: "configure log level (NONE, ..., TRACE)",
onPresentHandler: (x) => {
setGlobalLogLevelFromString(x);
},
})
.flag("version", ["-v", "--version"], {
onPresentHandler: printVersion,
})
.flag("verbose", ["-V", "--verbose"], {
help: "Enable verbose output.",
})
walletCli
.subcommand("serve", "serve", { help: "Create a server." })
.maybeOption("folder", ["-F", "--folder"], clk.STRING, {
help: "should complete",
// default: "./dist"
})
.maybeOption("port", ["-P", "--port"], clk.INT, {
help: "should complete",
// default: 8000
})
.flag("development", ["-D", "--dev"], {
help: "should complete",
})
.action(async (args) => {
return serve({
folder: args.serve.folder || "./dist",
port: args.serve.port || 8000,
development: args.serve.development
})
}
);
declare const __VERSION__: string;
function printVersion(): void {
console.log(__VERSION__);
process.exit(0);
}
export function main(): void {
walletCli.run();
}

12
packages/web-util/src/custom.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
declare module "*.crt" {
const content: string;
export default content;
}
declare module "*.key" {
const content: string;
export default content;
}
declare module "*.html" {
const content: string;
export default content;
}

View File

@ -0,0 +1,38 @@
//`ws://localhost:8003/socket`
export function setupLiveReload(wsURL: string | undefined) {
if (!wsURL) return;
const ws = new WebSocket(wsURL);
ws.addEventListener('message', (message) => {
const event = JSON.parse(message.data);
if (event.type === "LOG") {
console.log(event.message);
}
if (event.type === "RELOAD") {
window.location.reload();
}
if (event.type === "UPDATE") {
const c = document.getElementById("container")
if (c) {
document.body.removeChild(c);
}
const d = document.createElement("div");
d.setAttribute("id", "container");
d.setAttribute("class", "app-container");
document.body.appendChild(d);
const s = document.createElement("script");
s.setAttribute("id", "code");
s.setAttribute("type", "application/javascript");
s.textContent = atob(event.content);
document.body.appendChild(s);
}
});
ws.onerror = (error) => {
console.error(error);
};
ws.onclose = (e) => {
setTimeout(setupLiveReload, 500);
};
}
export { renderStories, parseGroupImport } from "./stories.js"

View File

@ -0,0 +1,3 @@
export { serve } from "./serve.js"

View File

@ -0,0 +1,4 @@
export default {}

View File

@ -0,0 +1,14 @@
-----BEGIN CERTIFICATE-----
MIICODCCAaGgAwIBAgIUH8AY7kGN1yzGEwQOZKeL26ZOQHAwDQYJKoZIhvcNAQEL
BQAwLjEVMBMGA1UEAwwMbG9jYWxob3N0LWNhMRUwEwYDVQQKDAxsb2NhbGhvc3Qt
Y2EwHhcNMjIxMTMwMjIwNjAxWhcNMjIxMjMwMjIwNjAxWjAuMRUwEwYDVQQDDAxs
b2NhbGhvc3QtY2ExFTATBgNVBAoMDGxvY2FsaG9zdC1jYTCBnzANBgkqhkiG9w0B
AQEFAAOBjQAwgYkCgYEAo2gw/oYcKxrSeDbVTTFX8pZA8fojGMwcQlSmeYMUrhtn
+PkXEvCTyMWcreLg2Y4sgdOjvK0ZM7OXnf/jx4fDiMpGy5BHT2ZJRWPzSh6UmNUy
kyeRAkDB3gCyQSHmmL1rEFOuwmq1yoT0FlIyTQ+mWrs5yg7QTe1rRyFWXHIt1TMC
AwEAAaNTMFEwHQYDVR0OBBYEFO1Op1KRMkVkzadGy2TZFQlwG9FFMB8GA1UdIwQY
MBaAFO1Op1KRMkVkzadGy2TZFQlwG9FFMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI
hvcNAQELBQADgYEAIdePTdDsD8IBFfHze9YVU+VZg3aNO5F/6QJPy/8InejQU0V8
9Cod19SEh3Kdlpa4QLvZH1cX+ac7bvhL0JaZg0dsz8UaZ8xrkEPx6JJAwgCiv/Ir
YqhoRd4fv/c6/B0yqD4Dhoy/jGkxfvc8XDnAuAP0uRttGwvsvHS9cSkHYFo=
-----END CERTIFICATE-----

View File

@ -0,0 +1,16 @@
-----BEGIN PRIVATE KEY-----
MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAKNoMP6GHCsa0ng2
1U0xV/KWQPH6IxjMHEJUpnmDFK4bZ/j5FxLwk8jFnK3i4NmOLIHTo7ytGTOzl53/
48eHw4jKRsuQR09mSUVj80oelJjVMpMnkQJAwd4AskEh5pi9axBTrsJqtcqE9BZS
Mk0Pplq7OcoO0E3ta0chVlxyLdUzAgMBAAECgYABkiDWcYeXynw3d595TH4h8NvS
96qatGuZH6MyC9aJDe5j8FEOd42UIoItEb9DmCBJZzVtvOQ/IPzWIf2Yj2+LvydI
qEA6ucroa9F9KG9T9ywNJfqM8fNzARQEAzK4/PglbT+n27hkNIm35BOA8PIUuBiD
pT6D0L0LHfNs6NkRAQJBAM9RS9ApnRmo4qV8kNJvysBJ/NO8PdLT47XIA2uPaAAT
O9NjrxGHaP0is+PIuwgTi9T5lyprpQss2yS9O7rN5PMCQQDJx0CMjkPDbelbWeH2
nOvyxLLCev69ae6zVrMPcE7vRPohlJTSK/kgouLr0F6lomK9HVugD7VgrQHuj9am
UV7BAkBhCHnlejSvl95M+lqGRBCvo3GUYJzHGqmPoYgIRdy1fEsaC6QbHjfDkwSD
bqYrh4qBKjjYf/2Fl38SWQelzUyFAkBoht27cl9MN/3xIsjZ1kSsiJUKBmk8ekn7
gWhVERry/EqPZscJcVonO/pNqq29JDf+O90hN8IACN+9U6ogknqBAkAr3SowHLyD
LfTrEDxeoAd2+K7gGKyrK3gyIISbuWtluONNPqenuFFHXxehwJ72VplNkpUZP4Bt
TQcIW9zIYT5r
-----END PRIVATE KEY-----

View File

@ -0,0 +1 @@
7488FC4F9D5E2BB55DEA16CF051F4E99ACA25241

View File

@ -0,0 +1,15 @@
-----BEGIN CERTIFICATE-----
MIICRTCCAa6gAwIBAgIUdIj8T51eK7Vd6hbPBR9OmayiUkEwDQYJKoZIhvcNAQEL
BQAwLjEVMBMGA1UEAwwMbG9jYWxob3N0LWNhMRUwEwYDVQQKDAxsb2NhbGhvc3Qt
Y2EwHhcNMjIxMTMwMjIwNjAyWhcNMjMxMTMwMjIwNjAyWjArMRIwEAYDVQQDDAls
b2NhbGhvc3QxFTATBgNVBAoMDGxvY2FsaG9zdC1jYTCBnzANBgkqhkiG9w0BAQEF
AAOBjQAwgYkCgYEAvir90pl9q6qUsBsBz7jjdw0r1DDPeViqkSyDDTt4Lw2zYJbn
Z7kxcTvNHNRWdtsWSzY/43ERCJu6nX60kMiML3NV00ty2VpaYeW9J5ozXgNbb+5P
esLHrIHmnOIUj46jyiHjDKs+hgrfcrFg7W7ndjW3dCAvkeAV+mncz59pFvkCAwEA
AaNjMGEwCQYDVR0TBAIwADAdBgNVHQ4EFgQUXADNSPivlIUBpKyd/XirIcqxqFgw
HwYDVR0jBBgwFoAU7U6nUpEyRWTNp0bLZNkVCXAb0UUwFAYDVR0RBA0wC4IJbG9j
YWxob3N0MA0GCSqGSIb3DQEBCwUAA4GBAClcLuKFnRJjAgP8652jJscYMLWYEkv3
j9kChErpKZNKiv+VlWKPiOvhZVAl+/YEsBOKXpRFX3CuLCdGtuv7b6NaH7yEXaZn
9MVIrYMRub3k0gVAhu3z3VXuvHFXdTms3KRlGdPdQV2xgpQJczDNnd7idp/GyI4j
KqBo0UxuWZBJ
-----END CERTIFICATE-----

View File

@ -0,0 +1,10 @@
-----BEGIN CERTIFICATE REQUEST-----
MIIBajCB1AIBADArMRIwEAYDVQQDDAlsb2NhbGhvc3QxFTATBgNVBAoMDGxvY2Fs
aG9zdC1jYTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAvir90pl9q6qUsBsB
z7jjdw0r1DDPeViqkSyDDTt4Lw2zYJbnZ7kxcTvNHNRWdtsWSzY/43ERCJu6nX60
kMiML3NV00ty2VpaYeW9J5ozXgNbb+5PesLHrIHmnOIUj46jyiHjDKs+hgrfcrFg
7W7ndjW3dCAvkeAV+mncz59pFvkCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4GBADJW
Ww+l4E///54fz82AE5x8U114Yk32EbB1qOfGLyXgoXySGyLuiNu40SXxioKa/Gpn
Z92o5JIrMVWUroPzMKAMXdAsixkaBGrT5RYzR9ztfy59djxp0f7dlL3ZxDO8JHOw
aTJXJxKEfYdv0oFhkx/u4ki6BsaqG9mQfsFXtlUp
-----END CERTIFICATE REQUEST-----

View File

@ -0,0 +1,16 @@
-----BEGIN PRIVATE KEY-----
MIICeQIBADANBgkqhkiG9w0BAQEFAASCAmMwggJfAgEAAoGBAL4q/dKZfauqlLAb
Ac+443cNK9Qwz3lYqpEsgw07eC8Ns2CW52e5MXE7zRzUVnbbFks2P+NxEQibup1+
tJDIjC9zVdNLctlaWmHlvSeaM14DW2/uT3rCx6yB5pziFI+Oo8oh4wyrPoYK33Kx
YO1u53Y1t3QgL5HgFfpp3M+faRb5AgMBAAECgYEAh1xgqdxZqKzWA3hl1K7dMmus
q/BGbjCf0JAnhG61QID3EqS3eIxI1jnj6UZ3eUi/WK/3z/Q2VLNMpTiAXKJzrUP0
8m7yO87AeUxhy0rvtWEVmd8NBQjJKD2iElgy6tR9QUsgTXer9xuQf0sHRQb1psNU
11WsBnwdzeEEzquORVUCQQDtJx/HjHDVTDF02W5B23J4oqwuu1EDCVDqNJiYSDSt
2Dh0IdvSKJyh9lXIoY+kbbEui8uPPnhPKM1LIRfiv7FHAkEAzUf1mvTBNUGCwjZu
qy/oKDR7TlEbdyDJY1F0JPquyim/CenRtM8VAH22Tni8+bSSpnHknytvKfaC0YFb
VN8VvwJBAKTdJgKbZ3Vg2qDY5wVxgUrMC9cQ8Wii+VVX6x0yVSzlu5lAUIjxIrKV
hV1Ms4cjmqE5HfIfA5REUTOBdhF0IdECQQC/1lia19Ha7/6/eljP17RQJkN5O+i7
2kL5crxkdnRz7rFeFUlpfAB3dgOxr7mCbZKCw3rQmKmJAJreKNHuLZBHAkEAwYZ4
tc4mWjtw4AMDK59o8d8ANObyuVaIy6I54NZ0ogg+0nzrXii9LkZZhAWwVSN9BdXa
TYVu0J5fGxDZVAm0zQ==
-----END PRIVATE KEY-----

View File

@ -0,0 +1,52 @@
/* eslint-disable no-undef */
function setupLiveReload(): void {
const ws = new WebSocket("wss://localhost:8080/ws");
ws.addEventListener("message", (message) => {
try {
const event = JSON.parse(message.data);
if (event.type === "file-updated-start") {
showReloadOverlay();
return;
}
if (event.type === "file-updated-done") {
window.location.reload();
return;
}
} catch (e) {
return
}
console.log("unsupported", event);
});
ws.addEventListener("error", (error) => {
console.error(error);
});
ws.addEventListener("close", (message) => {
setTimeout(setupLiveReload, 1500);
});
}
setupLiveReload();
function showReloadOverlay(): void {
const d = document.createElement("div");
d.style.position = "absolute";
d.style.width = "100%";
d.style.height = "100%";
d.style.color = "white";
d.style.backgroundColor = "rgba(0,0,0,0.5)";
d.style.display = "flex";
d.style.justifyContent = "center";
const h = document.createElement("h1");
h.style.margin = "auto";
h.innerHTML = "reloading...";
d.appendChild(h);
if (document.body.firstChild) {
document.body.insertBefore(d, document.body.firstChild);
} else {
document.body.appendChild(d);
}
}

View File

@ -0,0 +1,108 @@
import {
Logger
} from "@gnu-taler/taler-util";
import chokidar from 'chokidar';
import express from "express";
import https from "https";
import { parse } from 'url';
import WebSocket, { Server } from 'ws';
import locahostCrt from './keys/localhost.crt';
import locahostKey from './keys/localhost.key';
import storiesHtml from './stories.html';
import path from "path";
const httpServerOptions = {
key: locahostKey,
cert: locahostCrt
};
const logger = new Logger("serve.ts");
const PATHS = {
WS: "/ws",
NOTIFY: "/notify",
EXAMPLE: "/examples",
APP: "/app",
}
export async function serve(opts: {
folder: string,
port: number,
source?: string,
development?: boolean,
examplesLocationJs?: string,
examplesLocationCss?: string,
onUpdate?: () => Promise<void>;
}): Promise<void> {
const app = express()
app.use(PATHS.APP, express.static(opts.folder))
const server = https.createServer(httpServerOptions, app)
server.listen(opts.port);
logger.info(`serving ${opts.folder} on ${opts.port}`)
logger.info(` ${PATHS.APP}: application`)
logger.info(` ${PATHS.EXAMPLE}: examples`)
logger.info(` ${PATHS.WS}: websocket`)
logger.info(` ${PATHS.NOTIFY}: broadcast`)
if (opts.development) {
const wss = new Server({ noServer: true });
wss.on('connection', function connection(ws) {
ws.send('welcome');
});
server.on('upgrade', function upgrade(request, socket, head) {
const { pathname } = parse(request.url || "");
if (pathname === PATHS.WS) {
wss.handleUpgrade(request, socket, head, function done(ws) {
wss.emit('connection', ws, request);
});
} else {
socket.destroy();
}
});
const sendToAllClients = function (data: object): void {
wss.clients.forEach(function each(client) {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(data));
}
})
}
const watchingFolder = opts.source ?? opts.folder
logger.info(`watching ${watchingFolder} for change`)
chokidar.watch(watchingFolder).on('change', (path, stats) => {
logger.trace(`changed ${path}`)
sendToAllClients({ type: 'file-updated-start', data: { path } })
if (opts.onUpdate) {
opts.onUpdate().then(result => {
sendToAllClients({ type: 'file-updated-done', data: { path, result } })
})
} else {
sendToAllClients({ type: 'file-change-done', data: { path } })
}
})
app.get(PATHS.EXAMPLE, function (req: any, res: any) {
res.set('Content-Type', 'text/html')
res.send(storiesHtml
.replace('__EXAMPLES_JS_FILE_LOCATION__', opts.examplesLocationJs ?? `.${PATHS.APP}/stories.js`)
.replace('__EXAMPLES_CSS_FILE_LOCATION__', opts.examplesLocationCss ?? `.${PATHS.APP}/stories.css`))
})
app.get(PATHS.NOTIFY, function (req: any, res: any) {
res.send('ok')
})
}
}

View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<title>WebUtils: Stories</title>
<meta charset="utf-8" />
<link rel="icon" href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" />
<link
rel="stylesheet"
type="text/css"
href="__EXAMPLES_CSS_FILE_LOCATION__"
/>
<script type="module" src="__EXAMPLES_JS_FILE_LOCATION__"></script>
</head>
<body>
<taler-stories id="container"></taler-stories>
</body>
</html>

View File

@ -0,0 +1,580 @@
/*
This file is part of GNU Taler
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
import { setupI18n } from "@gnu-taler/taler-util";
import e from "express";
import {
ComponentChild,
ComponentChildren,
Fragment,
FunctionalComponent,
FunctionComponent,
h,
JSX,
render,
VNode,
} from "preact";
import { useEffect, useErrorBoundary, useState } from "preact/hooks";
const Page: FunctionalComponent = ({ children }): VNode => {
return (
<div
style={{
fontFamily: "Arial, Helvetica, sans-serif",
width: "100%",
display: "flex",
flexDirection: "row",
}}
>
{children}
</div>
);
};
const SideBar: FunctionalComponent<{ width: number }> = ({
width,
children,
}): VNode => {
return (
<div
style={{
minWidth: width,
height: "calc(100vh - 20px)",
overflowX: "hidden",
overflowY: "visible",
scrollBehavior: "smooth",
}}
>
{children}
</div>
);
};
const ResizeHandleDiv: FunctionalComponent<
JSX.HTMLAttributes<HTMLDivElement>
> = ({ children, ...props }): VNode => {
return (
<div
{...props}
style={{
width: 10,
backgroundColor: "#ddd",
cursor: "ew-resize",
}}
>
{children}
</div>
);
};
const Content: FunctionalComponent = ({ children }): VNode => {
return (
<div
style={{
width: "100%",
padding: 20,
}}
>
{children}
</div>
);
};
function findByGroupComponentName(
allExamples: Group[],
group: string,
component: string,
name: string,
): ExampleItem | undefined {
const gl = allExamples.filter((e) => e.title === group);
if (gl.length === 0) {
return undefined;
}
const cl = gl[0].list.filter((l) => l.name === component);
if (cl.length === 0) {
return undefined;
}
const el = cl[0].examples.filter((c) => c.name === name);
if (el.length === 0) {
return undefined;
}
return el[0];
}
function getContentForExample(
item: ExampleItem | undefined,
allExamples: Group[],
): FunctionalComponent {
if (!item)
return function SelectExampleMessage() {
return <div>select example from the list on the left</div>;
};
const example = findByGroupComponentName(
allExamples,
item.group,
item.component,
item.name,
);
if (!example) {
return function ExampleNotFoundMessage() {
return <div>example not found</div>;
};
}
return () => example.render.component(example.render.props);
}
function ExampleList({
name,
list,
selected,
onSelectStory,
}: {
name: string;
list: {
name: string;
examples: ExampleItem[];
}[];
selected: ExampleItem | undefined;
onSelectStory: (i: ExampleItem, id: string) => void;
}): VNode {
const [isOpen, setOpen] = useState(selected && selected.group === name);
return (
<ol style={{ padding: 4, margin: 0 }}>
<div
style={{ backgroundColor: "lightcoral", cursor: "pointer" }}
onClick={() => setOpen(!isOpen)}
>
{name}
</div>
<div style={{ display: isOpen ? undefined : "none" }}>
{list.map((k) => (
<li key={k.name}>
<dl style={{ margin: 0 }}>
<dt>{k.name}</dt>
{k.examples.map((r, i) => {
const e = encodeURIComponent;
const eId = `${e(r.group)}-${e(r.component)}-${e(r.name)}`;
const isSelected =
selected &&
selected.component === r.component &&
selected.group === r.group &&
selected.name === r.name;
return (
<dd
id={eId}
key={r.name}
style={{
backgroundColor: isSelected
? "green"
: i % 2
? "lightgray"
: "lightblue",
marginLeft: "1em",
padding: 4,
cursor: "pointer",
borderRadius: 4,
marginBottom: 4,
}}
>
<a
href={`#${eId}`}
style={{ color: "black" }}
onClick={(e) => {
e.preventDefault();
location.hash = `#${eId}`;
onSelectStory(r, eId);
history.pushState({}, "", `#${eId}`);
}}
>
{r.name}
</a>
</dd>
);
})}
</dl>
</li>
))}
</div>
</ol>
);
}
/**
* Prevents the UI from redirecting and inform the dev
* where the <a /> should have redirected
* @returns
*/
function PreventLinkNavigation({
children,
}: {
children: ComponentChildren;
}): VNode {
return (
<div
onClick={(e) => {
let t: any = e.target;
do {
if (t.localName === "a" && t.getAttribute("href")) {
alert(`should navigate to: ${t.attributes.href.value}`);
e.stopImmediatePropagation();
e.stopPropagation();
e.preventDefault();
return false;
}
} while ((t = t.parentNode));
return true;
}}
>
{children}
</div>
);
}
function ErrorReport({
children,
selected,
}: {
children: ComponentChild;
selected: ExampleItem | undefined;
}): VNode {
const [error, resetError] = useErrorBoundary();
//if there is an error, reset when unloading this component
useEffect(() => (error ? resetError : undefined));
if (error) {
return (
<div>
<p>Error was thrown trying to render</p>
{selected && (
<ul>
<li>
<b>group</b>: {selected.group}
</li>
<li>
<b>component</b>: {selected.component}
</li>
<li>
<b>example</b>: {selected.name}
</li>
<li>
<b>args</b>:{" "}
<pre>{JSON.stringify(selected.render.props, undefined, 2)}</pre>
</li>
</ul>
)}
<p>{error.message}</p>
<pre>{error.stack}</pre>
</div>
);
}
return <Fragment>{children}</Fragment>;
}
function getSelectionFromLocationHash(
hash: string,
allExamples: Group[],
): ExampleItem | undefined {
if (!hash) return undefined;
const parts = hash.substring(1).split("-");
if (parts.length < 3) return undefined;
return findByGroupComponentName(
allExamples,
decodeURIComponent(parts[0]),
decodeURIComponent(parts[1]),
decodeURIComponent(parts[2]),
);
}
function parseExampleImport(
group: string,
componentName: string,
im: MaybeComponent,
): ComponentItem {
const examples: ExampleItem[] = Object.entries(im)
.filter(([k]) => k !== "default")
.map(([exampleName, exampleValue]): ExampleItem => {
if (!exampleValue) {
throw Error(
`example "${exampleName}" from component "${componentName}" in group "${group}" is undefined`,
);
}
if (typeof exampleValue === "function") {
return {
group,
component: componentName,
name: exampleName,
render: {
component: exampleValue as FunctionComponent,
props: {},
},
};
}
const v: any = exampleValue;
if (
"component" in v &&
typeof v.component === "function" &&
"props" in v
) {
return {
group,
component: componentName,
name: exampleName,
render: v,
};
}
throw Error(
`example "${exampleName}" from component "${componentName}" in group "${group}" doesn't follow one of the two ways of example`,
);
});
return {
name: componentName,
examples,
};
}
export function parseGroupImport(
groups: Record<string, ComponentOrFolder>,
): Group[] {
return Object.entries(groups).map(([groupName, value]) => {
return {
title: groupName,
list: Object.entries(value).flatMap(([key, value]) =>
folder(groupName, value),
),
};
});
}
export interface Group {
title: string;
list: ComponentItem[];
}
export interface ComponentItem {
name: string;
examples: ExampleItem[];
}
export interface ExampleItem {
group: string;
component: string;
name: string;
render: {
component: FunctionalComponent;
props: object;
};
}
type ComponentOrFolder = MaybeComponent | MaybeFolder;
interface MaybeFolder {
default?: { title: string };
// [exampleName: string]: FunctionalComponent;
}
interface MaybeComponent {
// default?: undefined;
[exampleName: string]: undefined | object;
}
function folder(groupName: string, value: ComponentOrFolder): ComponentItem[] {
let title: string | undefined = undefined;
try {
title =
typeof value === "object" &&
typeof value.default === "object" &&
value.default !== undefined &&
"title" in value.default &&
typeof value.default.title === "string"
? value.default.title
: undefined;
} catch (e) {
throw Error(
`Could not defined if it is component or folder ${groupName}: ${JSON.stringify(
value,
undefined,
2,
)}`,
);
}
if (title) {
const c = parseExampleImport(groupName, title, value as MaybeComponent);
return [c];
}
return Object.entries(value).flatMap(([subkey, value]) =>
folder(groupName, value),
);
}
interface Props {
getWrapperForGroup: (name: string) => FunctionComponent;
examplesInGroups: Group[];
langs: Record<string, object>;
}
function Application({
langs,
examplesInGroups,
getWrapperForGroup,
}: Props): VNode {
const initialSelection = getSelectionFromLocationHash(
location.hash,
examplesInGroups,
);
const url = new URL(window.location.href);
const currentLang = url.searchParams.get("lang") || "en";
if (!langs["en"]) {
langs["en"] = {};
}
setupI18n(currentLang, langs);
const [selected, updateSelected] = useState<ExampleItem | undefined>(
initialSelection,
);
const [sidebarWidth, setSidebarWidth] = useState(200);
useEffect(() => {
if (location.hash) {
const hash = location.hash.substring(1);
const found = document.getElementById(hash);
if (found) {
setTimeout(() => {
found.scrollIntoView({
block: "center",
});
}, 10);
}
}
}, []);
const GroupWrapper = getWrapperForGroup(selected?.group || "default");
const ExampleContent = getContentForExample(selected, examplesInGroups);
//style={{ "--with-size": `${sidebarWidth}px` }}
return (
<Page>
{/* <LiveReload /> */}
<SideBar width={sidebarWidth}>
<div>
Language:
<select
value={currentLang}
onChange={(e) => {
const url = new URL(window.location.href);
url.searchParams.set("lang", e.currentTarget.value);
window.location.href = url.href;
}}
>
{Object.keys(langs).map((l) => (
<option key={l}>{l}</option>
))}
</select>
</div>
{examplesInGroups.map((group) => (
<ExampleList
key={group.title}
name={group.title}
list={group.list}
selected={selected}
onSelectStory={(item, htmlId) => {
document.getElementById(htmlId)?.scrollIntoView({
block: "center",
});
updateSelected(item);
}}
/>
))}
<hr />
</SideBar>
<ResizeHandle
onUpdate={(x) => {
setSidebarWidth((s) => s + x);
}}
/>
<Content>
<ErrorReport selected={selected}>
<PreventLinkNavigation>
<GroupWrapper>
<ExampleContent />
</GroupWrapper>
</PreventLinkNavigation>
</ErrorReport>
</Content>
</Page>
);
}
export interface Options {
id?: string;
strings?: any;
getWrapperForGroup?: (name: string) => FunctionComponent;
}
export function renderStories(
groups: Record<string, ComponentOrFolder>,
options: Options = {},
): void {
const examples = parseGroupImport(groups);
try {
const cid = options.id ?? "container";
const container = document.getElementById(cid);
if (!container) {
throw Error(
`container with id ${cid} not found, can't mount page contents`,
);
}
render(
<Application
examplesInGroups={examples}
getWrapperForGroup={options.getWrapperForGroup ?? (() => Fragment)}
langs={options.strings ?? { en: {} }}
/>,
container,
);
} catch (e) {
console.error("got error", e);
if (e instanceof Error) {
document.body.innerText = `Fatal error: "${e.message}". Please report this bug at https://bugs.gnunet.org/.`;
}
}
}
function ResizeHandle({ onUpdate }: { onUpdate: (x: number) => void }): VNode {
const [start, setStart] = useState<number | undefined>(undefined);
return (
<ResizeHandleDiv
onMouseDown={(e: any) => {
setStart(e.pageX);
console.log("active", e.pageX);
return false;
}}
onMouseMove={(e: any) => {
if (start !== undefined) {
onUpdate(e.pageX - start);
}
return false;
}}
onMouseUp={() => {
setStart(undefined);
return false;
}}
/>
);
}

View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"composite": true,
"target": "ES6",
"module": "ESNext",
"jsx": "react",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
"moduleResolution": "Node",
"sourceMap": true,
"lib": [
"es6"
],
"outDir": "lib",
"preserveSymlinks": true,
"skipLibCheck": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"strict": true,
"strictPropertyInitialization": false,
"noImplicitAny": true,
"noImplicitThis": true,
"incremental": true,
"esModuleInterop": true,
"importHelpers": true,
"rootDir": "./src",
"typeRoots": [
"./node_modules/@types"
]
},
"include": [
"src/**/*"
]
}