From 11f76a737810839859111aa435eae4c08fd0b7ae Mon Sep 17 00:00:00 2001 From: Morten Olsen Date: Mon, 19 May 2025 16:22:59 +0200 Subject: [PATCH] refact: ink based terminal view (#17) --- docs/README.md | 2 +- package.json | 5 + pnpm-lock.yaml | 337 ++++++++++++++++++ src/cli/cli.ts | 71 ++-- src/cli/ui/components/scrollable-markdown.tsx | 55 +++ src/cli/ui/hooks/terminal.ts | 39 ++ src/cli/ui/state/state.tsx | 80 +++++ src/cli/ui/ui.ts | 70 ---- src/cli/ui/ui.tsx | 69 ++++ src/execution/execution.ts | 25 +- src/execution/handlers/handlers.input.ts | 9 +- src/execution/handlers/handlers.javascript.ts | 3 +- src/execution/handlers/handlers.md.ts | 6 +- src/execution/handlers/handlers.raw-md.ts | 5 + src/utils/errors.ts | 44 +++ src/utils/input.ts | 60 ++++ tsconfig.json | 8 +- 17 files changed, 772 insertions(+), 116 deletions(-) create mode 100644 src/cli/ui/components/scrollable-markdown.tsx create mode 100644 src/cli/ui/hooks/terminal.ts create mode 100644 src/cli/ui/state/state.tsx delete mode 100644 src/cli/ui/ui.ts create mode 100644 src/cli/ui/ui.tsx create mode 100644 src/utils/errors.ts create mode 100644 src/utils/input.ts diff --git a/docs/README.md b/docs/README.md index d18f120..35a4075 100644 --- a/docs/README.md +++ b/docs/README.md @@ -51,7 +51,7 @@ Create a file named `example.md`: ::raw-md[./examples/getting-started.md] -### Rendering Documents +### Rendering Document You have two primary ways to render your `http.md` file: diff --git a/package.json b/package.json index 91f7ec5..51fb81e 100644 --- a/package.json +++ b/package.json @@ -28,9 +28,11 @@ "@pnpm/find-workspace-packages": "^6.0.9", "@types/blessed": "^0.1.25", "@types/chai": "^5.2.2", + "@types/ink": "^2.0.3", "@types/marked-terminal": "^6.1.1", "@types/mdast": "^4.0.4", "@types/node": "^22.15.18", + "@types/react": "^18.3.12", "@types/terminal-kit": "^2.5.7", "tsx": "^4.19.4", "typescript": "^5.8.3" @@ -44,11 +46,13 @@ "eventemitter3": "^5.0.1", "handlebars": "^4.7.8", "hastscript": "^9.0.1", + "ink": "^5.2.1", "marked": "^15.0.11", "marked-terminal": "^7.3.0", "mdast-util-to-markdown": "^2.1.2", "mdast-util-to-string": "^4.0.0", "mdast-util-toc": "^7.1.0", + "react": "^18.3.1", "rehype-stringify": "^10.0.1", "remark-behead": "^3.1.0", "remark-directive": "^4.0.0", @@ -59,6 +63,7 @@ "terminal-kit": "^3.1.2", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", + "wrap-ansi": "^9.0.0", "yaml": "^2.8.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aac4e83..d52e8df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: hastscript: specifier: ^9.0.1 version: 9.0.1 + ink: + specifier: ^5.2.1 + version: 5.2.1(@types/react@18.3.21)(react@18.3.1) marked: specifier: ^15.0.11 version: 15.0.11 @@ -47,6 +50,9 @@ importers: mdast-util-toc: specifier: ^7.1.0 version: 7.1.0 + react: + specifier: ^18.3.1 + version: 18.3.1 rehype-stringify: specifier: ^10.0.1 version: 10.0.1 @@ -77,6 +83,9 @@ importers: unist-util-visit: specifier: ^5.0.0 version: 5.0.0 + wrap-ansi: + specifier: ^9.0.0 + version: 9.0.0 yaml: specifier: ^2.8.0 version: 2.8.0 @@ -90,6 +99,9 @@ importers: '@types/chai': specifier: ^5.2.2 version: 5.2.2 + '@types/ink': + specifier: ^2.0.3 + version: 2.0.3(@types/react@18.3.21)(react@18.3.1) '@types/marked-terminal': specifier: ^6.1.1 version: 6.1.1 @@ -99,6 +111,9 @@ importers: '@types/node': specifier: ^22.15.18 version: 22.15.18 + '@types/react': + specifier: ^18.3.12 + version: 18.3.21 '@types/terminal-kit': specifier: ^2.5.7 version: 2.5.7 @@ -111,6 +126,10 @@ importers: packages: + '@alcalzone/ansi-tokenize@0.1.3': + resolution: {integrity: sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==} + engines: {node: '>=14.13.1'} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -455,6 +474,10 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/ink@2.0.3': + resolution: {integrity: sha512-DYKIKEJqhsGfQ/jgX0t9BzfHmBJ/9dBBT2MDsHAQRAfOPhEe7LZm5QeNBx1J34/e108StCPuJ3r4bh1y38kCJA==} + deprecated: This is a stub types definition. ink provides its own type definitions, so you do not need this installed. + '@types/marked-terminal@6.1.1': resolution: {integrity: sha512-DfoUqkmFDCED7eBY9vFUhJ9fW8oZcMAK5EwRDQ9drjTbpQa+DnBTQQCwWhTFVf4WsZ6yYcJTI8D91wxTWXRZZQ==} @@ -470,6 +493,12 @@ packages: '@types/node@22.15.18': resolution: {integrity: sha512-v1DKRfUdyW+jJhZNEI1PYy29S2YRxMV5AOO/x/SjKmW0acCIOqmbj6Haf9eHAhsPmrhlHSxEhv/1WszcLWV4cg==} + '@types/prop-types@15.7.14': + resolution: {integrity: sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==} + + '@types/react@18.3.21': + resolution: {integrity: sha512-gXLBtmlcRJeT09/sI4PxVwyrku6SaNUj/6cMubjE6T6XdY1fDmBL7r0nX0jbSZPU/Xr0KuwLLZh6aOYY5d91Xw==} + '@types/ssri@7.1.5': resolution: {integrity: sha512-odD/56S3B51liILSk5aXJlnYt99S6Rt9EFDDqGtJM26rKHApHcwyU/UoYHrzKkdkHMAIquGWCuHtQTbes+FRQw==} @@ -522,6 +551,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -538,6 +571,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -619,10 +656,18 @@ packages: resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} engines: {node: '>=6'} + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + cli-columns@4.0.0: resolution: {integrity: sha512-XW2Vg+w+L9on9wtwKpyzluIPCWXjaBahI7mTcYjx+BVIYD9c3yqcv/yKC7CmdCZat4rq2yiE1UMSJC5ivKfMtQ==} engines: {node: '>= 10'} + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cli-highlight@2.1.11: resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} engines: {node: '>=8.0.0', npm: '>=5.0.0'} @@ -632,6 +677,10 @@ packages: resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} engines: {node: 10.* || >= 12.*} + cli-truncate@4.0.0: + resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} + engines: {node: '>=18'} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -639,6 +688,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -656,6 +709,10 @@ packages: config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -664,6 +721,9 @@ packages: resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} engines: {node: '>=8'} + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + cwise-compiler@1.1.3: resolution: {integrity: sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ==} @@ -704,6 +764,9 @@ packages: resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} engines: {node: '>=12'} + emoji-regex@10.4.0: + resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -717,6 +780,9 @@ packages: error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + es-toolkit@1.38.0: + resolution: {integrity: sha512-OT3AxczYYd3W50bCj4V0hKoOAfqIy9tof0leNQYekEDxVKir3RTVTJOLij7VAe6fsCNsGhC0JqIkURpMXTCSEA==} + esbuild@0.25.4: resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==} engines: {node: '>=18'} @@ -726,6 +792,10 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -770,6 +840,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.3.0: + resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==} + engines: {node: '>=18'} + get-source@2.0.12: resolution: {integrity: sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==} @@ -828,6 +902,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + individual@3.0.0: resolution: {integrity: sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g==} @@ -841,6 +919,19 @@ packages: resolution: {integrity: sha512-it4HyVAUTKBc6m8e1iXWvXSTdndF7HbdN713+kvLrymxTaU4AUBWrJ4vEooP+V7fexnVD3LKcBshjGGPefSMUQ==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + ink@5.2.1: + resolution: {integrity: sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=18.0.0' + react: '>=18.0.0' + react-devtools-core: ^4.19.1 + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + iota-array@1.0.0: resolution: {integrity: sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==} @@ -867,6 +958,14 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-fullwidth-code-point@4.0.0: + resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} + engines: {node: '>=12'} + + is-fullwidth-code-point@5.0.0: + resolution: {integrity: sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==} + engines: {node: '>=18'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -874,6 +973,11 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-in-ci@1.0.0: + resolution: {integrity: sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==} + engines: {node: '>=18'} + hasBin: true + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -939,6 +1043,10 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + loupe@3.1.3: resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} @@ -1210,6 +1318,10 @@ packages: parse5@6.0.1: resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-absolute@1.0.1: resolution: {integrity: sha512-gds5iRhSeOcDtj8gfWkRHLtZKTPsFVuh7utbjYtvnclw4XM+ffRzJrwqMhOD1PVqef7nBLmgsu1vIujjvAJrAw==} engines: {node: '>=4'} @@ -1264,6 +1376,16 @@ packages: resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==} engines: {node: '>=8'} + react-reconciler@0.29.2: + resolution: {integrity: sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^18.3.1 + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + read-ini-file@4.0.0: resolution: {integrity: sha512-zz4qv/sKETv7nAkATqSJ9YMbKD8NXRPuA8d17VdYCuNYrVstB1S6UAMU6aytf5vRa9MESbZN7jLZdcmrOxz4gg==} engines: {node: '>=14.6'} @@ -1309,6 +1431,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -1331,6 +1457,9 @@ packages: resolution: {integrity: sha512-vdTshSQ2JsRCgT8eKZWNJIL26C6bVqy1SOmuCMlKHegVeo8KYRobRrefOdUq9OozSPUUiSxrylteeRmLOMFfWg==} engines: {node: '>=12'} + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + semver@7.7.2: resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} engines: {node: '>=10'} @@ -1362,6 +1491,14 @@ packages: resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} engines: {node: '>=8'} + slice-ansi@5.0.0: + resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} + engines: {node: '>=12'} + + slice-ansi@7.1.0: + resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} + engines: {node: '>=18'} + sort-keys@4.2.0: resolution: {integrity: sha512-aUYIEU/UviqPgc8mHR6IW1EGxkAXpeRETYcrzg8cLAvUPZcpAlleSXHV2mY7G12GphSH6Gzv+4MMVSSkbdteHg==} engines: {node: '>=8'} @@ -1376,6 +1513,10 @@ packages: split2@3.2.2: resolution: {integrity: sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==} + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + stacktracey@2.1.8: resolution: {integrity: sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==} @@ -1391,6 +1532,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -1401,6 +1546,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + strip-bom@4.0.0: resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} engines: {node: '>=8'} @@ -1464,6 +1613,10 @@ packages: resolution: {integrity: sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==} engines: {node: '>=8'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typescript@5.8.3: resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} engines: {node: '>=14.17'} @@ -1563,6 +1716,10 @@ packages: resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} engines: {node: '>=8'} + widest-line@5.0.0: + resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} + engines: {node: '>=18'} + wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} @@ -1570,6 +1727,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@9.0.0: + resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} + engines: {node: '>=18'} + write-file-atomic@5.0.1: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -1578,6 +1739,18 @@ packages: resolution: {integrity: sha512-FdNA4RyH1L43TlvGG8qOMIfcEczwA5ij+zLXUy3Z83CjxhLvcV7/Q/8pk22wnCgYw7PJhtK+7lhO+qqyT4NdvQ==} engines: {node: '>=16.14'} + ws@8.18.2: + resolution: {integrity: sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -1595,11 +1768,19 @@ packages: resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} engines: {node: '>=10'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} snapshots: + '@alcalzone/ansi-tokenize@0.1.3': + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 4.0.0 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -1965,6 +2146,16 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/ink@2.0.3(@types/react@18.3.21)(react@18.3.1)': + dependencies: + ink: 5.2.1(@types/react@18.3.21)(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - bufferutil + - react + - react-devtools-core + - utf-8-validate + '@types/marked-terminal@6.1.1': dependencies: '@types/cardinal': 2.1.1 @@ -1984,6 +2175,13 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/prop-types@15.7.14': {} + + '@types/react@18.3.21': + dependencies: + '@types/prop-types': 15.7.14 + csstype: 3.1.3 + '@types/ssri@7.1.5': dependencies: '@types/node': 22.15.18 @@ -2031,6 +2229,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.1: {} + any-promise@1.3.0: {} archy@1.0.0: {} @@ -2043,6 +2243,8 @@ snapshots: assertion-error@2.0.1: {} + auto-bind@5.0.1: {} + bail@2.0.2: {} better-path-resolve@1.0.0: @@ -2118,11 +2320,17 @@ snapshots: cli-boxes@2.2.1: {} + cli-boxes@3.0.0: {} + cli-columns@4.0.0: dependencies: string-width: 4.2.3 strip-ansi: 6.0.1 + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + cli-highlight@2.1.11: dependencies: chalk: 4.1.2 @@ -2138,6 +2346,11 @@ snapshots: optionalDependencies: '@colors/colors': 1.5.0 + cli-truncate@4.0.0: + dependencies: + slice-ansi: 5.0.0 + string-width: 7.2.0 + cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -2146,6 +2359,10 @@ snapshots: clone@1.0.4: {} + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2161,6 +2378,8 @@ snapshots: ini: 1.3.8 proto-list: 1.2.4 + convert-to-spaces@2.0.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2169,6 +2388,8 @@ snapshots: crypto-random-string@2.0.0: {} + csstype@3.1.3: {} + cwise-compiler@1.1.3: dependencies: uniq: 1.0.1 @@ -2199,6 +2420,8 @@ snapshots: dotenv@16.5.0: {} + emoji-regex@10.4.0: {} + emoji-regex@8.0.0: {} emojilib@2.4.0: {} @@ -2209,6 +2432,8 @@ snapshots: dependencies: is-arrayish: 0.2.1 + es-toolkit@1.38.0: {} + esbuild@0.25.4: optionalDependencies: '@esbuild/aix-ppc64': 0.25.4 @@ -2239,6 +2464,8 @@ snapshots: escalade@3.2.0: {} + escape-string-regexp@2.0.0: {} + escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} @@ -2284,6 +2511,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.3.0: {} + get-source@2.0.12: dependencies: data-uri-to-buffer: 2.0.2 @@ -2354,6 +2583,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@5.0.0: {} + individual@3.0.0: {} inherits@2.0.4: {} @@ -2362,6 +2593,39 @@ snapshots: ini@3.0.1: {} + ink@5.2.1(@types/react@18.3.21)(react@18.3.1): + dependencies: + '@alcalzone/ansi-tokenize': 0.1.3 + ansi-escapes: 7.0.0 + ansi-styles: 6.2.1 + auto-bind: 5.0.1 + chalk: 5.4.1 + cli-boxes: 3.0.0 + cli-cursor: 4.0.0 + cli-truncate: 4.0.0 + code-excerpt: 4.0.0 + es-toolkit: 1.38.0 + indent-string: 5.0.0 + is-in-ci: 1.0.0 + patch-console: 2.0.0 + react: 18.3.1 + react-reconciler: 0.29.2(react@18.3.1) + scheduler: 0.23.2 + signal-exit: 3.0.7 + slice-ansi: 7.1.0 + stack-utils: 2.0.6 + string-width: 7.2.0 + type-fest: 4.41.0 + widest-line: 5.0.0 + wrap-ansi: 9.0.0 + ws: 8.18.2 + yoga-layout: 3.2.1 + optionalDependencies: + '@types/react': 18.3.21 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + iota-array@1.0.0: {} is-alphabetical@2.0.1: {} @@ -2381,12 +2645,20 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@4.0.0: {} + + is-fullwidth-code-point@5.0.0: + dependencies: + get-east-asian-width: 1.3.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 is-hexadecimal@2.0.1: {} + is-in-ci@1.0.0: {} + is-number@7.0.0: {} is-plain-obj@2.1.0: {} @@ -2432,6 +2704,10 @@ snapshots: longest-streak@3.1.0: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + loupe@3.1.3: {} map-age-cleaner@0.1.3: @@ -2906,6 +3182,8 @@ snapshots: parse5@6.0.1: {} + patch-console@2.0.0: {} + path-absolute@1.0.1: {} path-key@3.1.1: {} @@ -2940,6 +3218,16 @@ snapshots: quick-lru@4.0.1: {} + react-reconciler@0.29.2(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + read-ini-file@4.0.0: dependencies: ini: 3.0.1 @@ -3019,6 +3307,11 @@ snapshots: resolve-pkg-maps@1.0.0: {} + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + reusify@1.1.0: {} right-pad@1.1.1: {} @@ -3039,6 +3332,10 @@ snapshots: execa: 5.1.1 path-name: 1.0.0 + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + semver@7.7.2: {} setimmediate@1.0.5: {} @@ -3061,6 +3358,16 @@ snapshots: dependencies: unicode-emoji-modifier-base: 1.0.0 + slice-ansi@5.0.0: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 4.0.0 + + slice-ansi@7.1.0: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 5.0.0 + sort-keys@4.2.0: dependencies: is-plain-obj: 2.1.0 @@ -3073,6 +3380,10 @@ snapshots: dependencies: readable-stream: 3.6.2 + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stacktracey@2.1.8: dependencies: as-table: 1.0.55 @@ -3091,6 +3402,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@7.2.0: + dependencies: + emoji-regex: 10.4.0 + get-east-asian-width: 1.3.0 + strip-ansi: 7.1.0 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -3104,6 +3421,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.1.0 + strip-bom@4.0.0: {} strip-comments-strings@1.2.0: {} @@ -3165,6 +3486,8 @@ snapshots: type-fest@0.6.0: {} + type-fest@4.41.0: {} + typescript@5.8.3: {} uglify-js@3.19.3: @@ -3289,6 +3612,10 @@ snapshots: dependencies: string-width: 4.2.3 + widest-line@5.0.0: + dependencies: + string-width: 7.2.0 + wordwrap@1.0.0: {} wrap-ansi@7.0.0: @@ -3297,6 +3624,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 7.2.0 + strip-ansi: 7.1.0 + write-file-atomic@5.0.1: dependencies: imurmurhash: 0.1.4 @@ -3307,6 +3640,8 @@ snapshots: js-yaml: 4.1.0 write-file-atomic: 5.0.1 + ws@8.18.2: {} + y18n@5.0.8: {} yaml@2.8.0: {} @@ -3323,4 +3658,6 @@ snapshots: y18n: 5.0.8 yargs-parser: 20.2.9 + yoga-layout@3.2.1: {} + zwitch@2.0.4: {} diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 0e47fe0..a0b467c 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -1,13 +1,14 @@ import { program } from 'commander'; import { resolve } from 'node:path'; -import { Marked } from 'marked'; -import { markedTerminal } from 'marked-terminal'; import { execute } from '../execution/execution.js'; import { Context } from '../context/context.js'; import { writeFile } from 'node:fs/promises'; import { Watcher } from '../watcher/watcher.js'; -import { UI } from './ui/ui.js'; import { wrapBody } from '../theme/theme.html.js'; +import { loadInputFiles } from '../utils/input.js'; +import { InvalidFormatError } from '../utils/errors.js'; +import { renderUI, State } from './ui/ui.js'; +import { Marked } from 'marked'; @@ -16,23 +17,27 @@ program .argument('', 'http.md file name') .description('Run a http.md document') .option('-w, --watch', 'watch for changes') + .option('-f, --file ', 'input files (-f foo.js -f bar.json)') .option('-i, --input ', 'input variables (-i foo=bar -i baz=qux)') .action(async (name, options) => { - const marked = new Marked(); - marked.use(markedTerminal() as any); const { + file: f = [], watch = false, input: i = [], } = options; - const ui = new UI(); - - const input = Object.fromEntries( - i.map((item: string) => { - const [key, value] = item.split('='); - return [key, value]; - }) - ); + const input = { + ...Object.fromEntries( + i.map((item: string) => { + const [key, value] = item.split('='); + return [key, value]; + }) + ), + ...loadInputFiles(f), + }; + const state = new State({ + markdown: 'Loading', + }); const filePath = resolve(process.cwd(), name); const build = async () => { @@ -43,8 +48,10 @@ program context, }); - const markdown = await marked.parse(result.markdown); - ui.content = markdown; + state.setState({ + error: result.error ? result.error instanceof Error ? result.error.message : result.error : undefined, + markdown: result.markdown, + }); return { ...result, @@ -53,10 +60,7 @@ program } const result = await build(); - - ui.screen.key(['r'], () => { - build(); - }); + renderUI(state); if (watch) { const watcher = new Watcher(); @@ -75,23 +79,28 @@ program .argument('', 'http.md file name') .argument('', 'output file name') .description('Run a http.md document') - .option('-f, --format ', 'output format (html, markdown)') + .option('-f, --file ', 'input files (-f foo.js -f bar.json)') + .option('--format ', 'output format (html, markdown)') .option('-w, --watch', 'watch for changes') .option('-i, --input ', 'input variables (-i foo=bar -i baz=qux)') .action(async (name, output, options) => { const { watch = false, + file: f = [], input: i = [], format = 'markdown', } = options; - const input = Object.fromEntries( - i.map((item: string) => { - const [key, value] = item.split('='); - return [key, value]; - }) - ); + const input = { + ...Object.fromEntries( + i.map((item: string) => { + const [key, value] = item.split('='); + return [key, value]; + }) + ), + ...loadInputFiles(f), + } const filePath = resolve(process.cwd(), name); const build = async () => { @@ -102,6 +111,10 @@ program context, }); + if (result.error) { + console.error(result.error); + } + if (format === 'html') { const marked = new Marked(); const html = await marked.parse(result.markdown); @@ -109,7 +122,7 @@ program } else if (format === 'markdown') { await writeFile(output, result.markdown); } else { - throw new Error('Invalid format'); + throw new InvalidFormatError('Invalid format'); } return { ...result, @@ -119,6 +132,10 @@ program const result = await build(); + if (result.error && !watch) { + process.exit(1); + } + if (watch) { const watcher = new Watcher(); watcher.watchFiles(Array.from(result.context.files)); diff --git a/src/cli/ui/components/scrollable-markdown.tsx b/src/cli/ui/components/scrollable-markdown.tsx new file mode 100644 index 0000000..7f87b35 --- /dev/null +++ b/src/cli/ui/components/scrollable-markdown.tsx @@ -0,0 +1,55 @@ +import React, { useMemo } from 'react'; +import { Box, Text, useInput } from 'ink'; +import { marked } from 'marked'; +import TerminalRenderer from 'marked-terminal'; +import wrapAnsi from 'wrap-ansi'; +import os from 'os'; +import { useTerminalHeight, useTerminalWidth } from '../hooks/terminal.js'; + +type ScrollableMarkdownProps = { + markdownContent: string; +}; + +const renderer = new TerminalRenderer({}); +marked.setOptions({ renderer: renderer as any }); + +const ScrollableMarkdown = ({ markdownContent }: ScrollableMarkdownProps) => { + const [scrollPosition, setScrollPosition] = React.useState(0); + const terminalHeight = useTerminalHeight() + const terminalWidth = useTerminalWidth(); + const rendered = useMemo(() => { + return marked(markdownContent) as string; + }, [markdownContent]); + const wrapped = useMemo(() => { + return wrapAnsi(rendered, terminalWidth, { + hard: true, + trim: false, + wordWrap: true, + }).split(os.EOL); + }, [rendered, terminalWidth]); + + const buffer = useMemo(() => { + return wrapped.slice(scrollPosition, scrollPosition + terminalHeight).join(os.EOL); + }, [wrapped, scrollPosition, terminalHeight]); + + useInput((input, key) => { + if (key.downArrow) { + setScrollPosition(prev => prev + 1); + } + if (key.upArrow) { + setScrollPosition(prev => Math.max(0, prev - 1)); + } + if (key.escape) { + process.exit(0); + } + console.error('a', input, key); + }); + + return ( + + {buffer} + + ); +}; + +export { ScrollableMarkdown }; diff --git a/src/cli/ui/hooks/terminal.ts b/src/cli/ui/hooks/terminal.ts new file mode 100644 index 0000000..f0c674a --- /dev/null +++ b/src/cli/ui/hooks/terminal.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from "react" + +const useTerminalWidth = () => { + const [width, setWidth] = useState(process.stdout.columns || 80); + + useEffect(() => { + const handleResize = () => { + setWidth(process.stdout.columns); + }; + + process.stdout.on("resize", handleResize); + + return () => { + process.stdout.off("resize", handleResize); + }; + }, []); + + return width; +} + +const useTerminalHeight = () => { + const [height, setHeight] = useState(process.stdout.rows || 24); + + useEffect(() => { + const handleResize = () => { + setHeight(process.stdout.rows); + }; + + process.stdout.on("resize", handleResize); + + return () => { + process.stdout.off("resize", handleResize); + }; + }, []); + + return height; +} + +export { useTerminalWidth, useTerminalHeight } diff --git a/src/cli/ui/state/state.tsx b/src/cli/ui/state/state.tsx new file mode 100644 index 0000000..53bf0ac --- /dev/null +++ b/src/cli/ui/state/state.tsx @@ -0,0 +1,80 @@ +import { EventEmitter } from 'eventemitter3'; +import React, { createContext, useRef, useState, useSyncExternalStore } from "react"; + +type StateEvents = { + update: () => void; +} + +class State extends EventEmitter { + state: T; + + constructor(initialState: T) { + super(); + this.state = initialState; + } + + public get value() { + return this.state; + } + + public setState = (state: T) => { + this.state = state; + this.emit('update'); + } + + public subscribe = (callback: () => void) => { + this.on('update', callback); + return () => { + this.off('update', callback); + }; + } +} + +type StateContextValue = { + state: State; +} + +const StateContext = createContext | null>(null); + +type StateProviderProps = { + state: State; + children: React.ReactNode; +} + +const StateProvider = ({ state, children }: StateProviderProps) => { + return ( + + {children} + + ); +}; + +const useStateContext = () => { + const context = React.useContext(StateContext); + if (!context) { + throw new Error("useStateContext must be used within a StateProvider"); + } + return context as StateContextValue; +} + +const useStateValue = ( + selector: (state: T) => any = (state) => state, +) => { + const context = useStateContext(); + const value = useRef(selector(context.state.value)); + useSyncExternalStore( + context.state.subscribe, + () => { + const next = selector(context.state.value); + if (next !== value.current) { + value.current = next; + } + return value.current; + }, + () => value.current, + ); + + return value.current; +} + +export { State, StateProvider, useStateContext, useStateValue }; diff --git a/src/cli/ui/ui.ts b/src/cli/ui/ui.ts deleted file mode 100644 index a52231f..0000000 --- a/src/cli/ui/ui.ts +++ /dev/null @@ -1,70 +0,0 @@ -import blessed from 'blessed'; -import chalk from 'chalk'; - -class UI { - #box: blessed.Widgets.BoxElement; - #screen: blessed.Widgets.Screen; - - constructor() { - const screen = blessed.screen({ - smartCSR: true, - title: 'Markdown Viewer' - }); - const scrollableBox = blessed.box({ // Or blessed.scrollablebox - parent: screen, - top: 0, - left: 0, - width: '100%', - height: '100%', - content: '', - scrollable: true, - alwaysScroll: true, - keys: true, - vi: true, // vi-like keybindings - mouse: true, - scrollbar: { - ch: ' ', - track: { - bg: 'cyan' - }, - style: { - inverse: true - } - }, - style: { - fg: 'white', - bg: 'black' - } - }); - this.#box = scrollableBox; - this.#screen = screen; - - screen.key(['escape', 'q', 'C-c'], () => { - return process.exit(0); - }); - - scrollableBox.focus(); - screen.render(); - } - - public get screen() { - return this.#screen; - } - - public set content(content: string) { - const originalLines = content.split('\n'); - const maxLineNoDigits = String(originalLines.length).length; // For padding - - const linesWithNumbers = originalLines.map((line, index) => { - const lineNumber = String(index + 1).padStart(maxLineNoDigits, ' '); - const styledLineNumber = chalk.dim.yellow(`${lineNumber} | `); - return `${styledLineNumber}${line}`; - }); - - const contentWithLineNumbers = linesWithNumbers.join('\n'); - this.#box.setContent(contentWithLineNumbers); - this.#screen.render(); - } -} - -export { UI }; diff --git a/src/cli/ui/ui.tsx b/src/cli/ui/ui.tsx new file mode 100644 index 0000000..6729eb1 --- /dev/null +++ b/src/cli/ui/ui.tsx @@ -0,0 +1,69 @@ +import { Box, render, Text, useApp, useInput } from "ink" +import { ScrollableMarkdown } from './components/scrollable-markdown.js'; +import { useStateValue, State, StateProvider } from './state/state.js'; +import { useEffect, useState } from "react"; +import { useTerminalHeight } from "./hooks/terminal.js"; + +const MarkdownView = () => { + const markdown = useStateValue((state) => state.markdown); + const error = useStateValue((state) => state.error); + + return ( + + + + + {error && ( + + {error} + + )} + + ); +} + +const App = () => { + const { exit } = useApp(); + const height = useTerminalHeight(); + useInput((_input, key) => { + if (key.escape) { + process.exit(0); + } + }); + useEffect( + () => { + const enterAltScreenCommand = "\x1b[?1049h"; + const leaveAltScreenCommand = "\x1b[?1049l"; + process.stdout.write(enterAltScreenCommand); + const onExit = () => { + exit(); + process.stdout.write(leaveAltScreenCommand); + } + process.on("exit", onExit); + return () => { + process.stdout.write(leaveAltScreenCommand); + process.off("exit", onExit); + } + }, [] + ); + + return ( + + + + Press esc to exit + + + ) +} + +const renderUI = (state: State) => { + + render( + + + + ); +} + +export { renderUI, State } diff --git a/src/execution/execution.ts b/src/execution/execution.ts index aca909c..40b765f 100644 --- a/src/execution/execution.ts +++ b/src/execution/execution.ts @@ -51,6 +51,7 @@ type ExexutionExecuteOptions = { } const execute = async (file: string, options: ExexutionExecuteOptions) => { + let error: unknown | undefined; const { context } = options; context.files.add(file); const content = await readFile(file, 'utf-8'); @@ -94,20 +95,26 @@ const execute = async (file: string, options: ExexutionExecuteOptions) => { }); for (const step of steps) { - const { node, action } = step; - const options: ExecutionStepOptions = { - file, - input: {}, - context, - node, - root, - }; - await action(options); + try { + const { node, action } = step; + const options: ExecutionStepOptions = { + file, + input: {}, + context, + node, + root, + }; + await action(options); + } catch (e) { + error = e; + break; + } } const markdown = parser.stringify(root); return { + error, root, markdown, }; diff --git a/src/execution/handlers/handlers.input.ts b/src/execution/handlers/handlers.input.ts index 7c55352..419bb28 100644 --- a/src/execution/handlers/handlers.input.ts +++ b/src/execution/handlers/handlers.input.ts @@ -1,5 +1,6 @@ import { toString } from 'mdast-util-to-string'; import { type ExecutionHandler } from '../execution.js'; +import { ParsingError, RequiredError } from '../../utils/errors.js'; const inputHandler: ExecutionHandler = ({ addStep, @@ -15,7 +16,7 @@ const inputHandler: ExecutionHandler = ({ const name = toString(node); if (node.attributes?.required === '' && context.input[name] === undefined) { - throw new Error(`Input "${name}" is required`); + throw new RequiredError(name); } if (node.attributes?.default !== undefined && context.input[name] === undefined) { @@ -27,7 +28,7 @@ const inputHandler: ExecutionHandler = ({ if (format === 'number') { context.input[name] = Number(context.input[name]); if (context.input[name] !== undefined && isNaN(Number(context.input[name]))) { - throw new Error(`Input "${name}" must be a number, but got "${context.input[name]}"`); + throw new ParsingError(`Input "${name}" must be a number, but got "${context.input[name]}"`); } } if (format === 'boolean') { @@ -40,14 +41,14 @@ const inputHandler: ExecutionHandler = ({ try { context.input[name] = JSON.parse(String(context.input[name])); } catch (error) { - throw new Error(`Input "${name}" must be a valid JSON, but got "${context.input[name]}"`); + throw new ParsingError(`Input "${name}" must be a valid JSON, but got "${context.input[name]}"`); } } if (format === 'date') { const date = new Date(context.input[name] as string); if (isNaN(date.getTime())) { - throw new Error(`Input "${name}" must be a valid date, but got "${context.input[name]}"`); + throw new ParsingError(`Input "${name}" must be a valid date, but got "${context.input[name]}"`); } context.input[name] = date; } diff --git a/src/execution/handlers/handlers.javascript.ts b/src/execution/handlers/handlers.javascript.ts index 3c6623b..5741eb5 100644 --- a/src/execution/handlers/handlers.javascript.ts +++ b/src/execution/handlers/handlers.javascript.ts @@ -2,6 +2,7 @@ import Handlebars from "handlebars"; import YAML from "yaml"; import { should, expect, assert } from 'chai'; import { ExecutionHandler } from "../execution.js"; +import { ScriptError } from "../../utils/errors.js"; const javascriptHandler: ExecutionHandler = ({ node, @@ -60,7 +61,7 @@ const javascriptHandler: ExecutionHandler = ({ meta: undefined, }); } - throw error; + throw new ScriptError(error instanceof Error ? error.message : String(error)) } } if (options.hidden === true && parent && index !== undefined) { diff --git a/src/execution/handlers/handlers.md.ts b/src/execution/handlers/handlers.md.ts index ccf68b9..5990992 100644 --- a/src/execution/handlers/handlers.md.ts +++ b/src/execution/handlers/handlers.md.ts @@ -1,6 +1,8 @@ import { dirname, resolve } from 'path'; import { toString } from 'mdast-util-to-string' import { execute, type ExecutionHandler } from '../execution.js'; +import { FileNotFoundError } from '../../utils/errors.js'; +import { existsSync } from 'fs'; const fileHandler: ExecutionHandler = ({ addStep, @@ -18,8 +20,8 @@ const fileHandler: ExecutionHandler = ({ dirname(file), toString(node) ); - if (!filePath) { - throw new Error('File path is required'); + if (!existsSync(filePath)) { + throw new FileNotFoundError(filePath); } const { root: newRoot } = await execute(filePath, { context, diff --git a/src/execution/handlers/handlers.raw-md.ts b/src/execution/handlers/handlers.raw-md.ts index 3553f3f..e705cdf 100644 --- a/src/execution/handlers/handlers.raw-md.ts +++ b/src/execution/handlers/handlers.raw-md.ts @@ -3,6 +3,8 @@ import { Context } from '../../context/context.js'; import { execute, type ExecutionHandler } from '../execution.js'; import { dirname, resolve } from 'path'; import { toString } from 'mdast-util-to-string'; +import { FileNotFoundError } from '../../utils/errors.js'; +import { existsSync } from 'fs'; const rawMdHandler: ExecutionHandler = ({ addStep, @@ -20,6 +22,9 @@ const rawMdHandler: ExecutionHandler = ({ dirname(file), toString(node), ); + if (!existsSync(name)) { + throw new FileNotFoundError(name); + } const context = new Context({ input: {}, }); diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..faaf72c --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,44 @@ +class BaseError extends Error { + constructor(message: string) { + super(message); + this.name = this.constructor.name; + } +} + +class FileNotFoundError extends BaseError { + constructor(filePath: string) { + super(`File not found: ${filePath}`); + } +} + +class InvalidFileError extends BaseError { + constructor(filePath: string) { + super(`Invalid file: ${filePath}`); + } +} + +class InvalidFormatError extends BaseError { + constructor(format: string) { + super(`Invalid format: ${format}`); + } +} + +class ScriptError extends BaseError { + constructor(message: string) { + super(`Script error: ${message}`); + } +} + +class ParsingError extends BaseError { + constructor(message: string) { + super(`Parsing error: ${message}`); + } +} + +class RequiredError extends BaseError { + constructor(name: string) { + super(`Required input "${name}" is missing`); + } +} + +export { BaseError, FileNotFoundError, InvalidFileError, InvalidFormatError, ScriptError, ParsingError, RequiredError }; diff --git a/src/utils/input.ts b/src/utils/input.ts new file mode 100644 index 0000000..65f9114 --- /dev/null +++ b/src/utils/input.ts @@ -0,0 +1,60 @@ +import { extname } from "path"; +import { FileNotFoundError, InvalidFileError } from "./errors.js"; +import { existsSync } from "fs"; +import YAML from "yaml"; +import { readFile } from "fs/promises"; + +const loadJsonFile = async (filePath: string) => { + const content = await readFile(filePath, "utf-8"); + return JSON.parse(content); +} + +const loadYamlFile = async (filePath: string) => { + const content = await readFile(filePath, "utf-8"); + return YAML.parse(content); +} + +const loadJsFile = async (filePath: string) => { + const { default: content } = await import(filePath); + return content; +} + + +const loadInputFiles = async (filePaths: string[]) => { + let inputs: Record = {}; + for (const filePath of filePaths) { + const type = extname(filePath); + if (!existsSync(filePath)) { + throw new FileNotFoundError(filePath); + } + try { + switch (type) { + case ".json": + inputs = { + ...inputs, + ...(await loadJsonFile(filePath)), + }; + break; + case ".yaml": + case ".yml": + inputs = { + ...inputs, + ...(await loadYamlFile(filePath)), + }; + break; + case ".js": + inputs = { + ...inputs, + ...(await loadJsFile(filePath)), + }; + break; + default: + throw new InvalidFileError(filePath); + } + } catch (error) { + throw new InvalidFileError(filePath); + } + } +} + +export { loadInputFiles }; diff --git a/tsconfig.json b/tsconfig.json index 5c4e5f9..fc01883 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,11 @@ "resolveJsonModule": true, "allowSyntheticDefaultImports": true, "skipLibCheck": true, - "outDir": "dist" + "outDir": "dist", + "jsx": "react-jsx" }, - "include": ["src/**/*.ts"] + "include": [ + "src/**/*.ts", + "src/cli/ui/ui.tsx" + ] }