Compare commits
519 commits
v0.0.1-alp
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72c171709e | ||
|
|
5dba9d0167 | ||
|
|
6f29b307ec | ||
|
|
06f01b3d7c | ||
|
|
d555e5337f | ||
|
|
c406b5f068 | ||
|
|
c52f10f92d | ||
|
|
d9c7fb3936 | ||
|
|
66b6dfffd3 | ||
|
|
24b41a7604 | ||
|
|
0c84e42160 | ||
|
|
ca4faf103e | ||
|
|
4f537567ad | ||
|
|
0d61b12561 | ||
|
|
3f719d7011 | ||
|
|
331f4cde82 | ||
|
|
ab50eaecf4 | ||
|
|
baf10419f7 | ||
|
|
c924f63ffb | ||
|
|
ab342535d3 | ||
|
|
15655d5c02 | ||
|
|
14272d6507 | ||
|
|
d325677a1f | ||
|
|
e0bb30b976 | ||
|
|
baf5292212 | ||
|
|
9b69b25d0b | ||
|
|
495e759689 | ||
|
|
ba8c0106d9 | ||
|
|
4c942a0a16 | ||
|
|
407cc12079 | ||
|
|
01e4568c48 | ||
|
|
158a6264a9 | ||
|
|
909d38c7f4 | ||
|
|
825c4dd7ec | ||
|
|
ef83e34b1e | ||
|
|
a1af01a480 | ||
|
|
971f05c005 | ||
|
|
b06d1b14bd | ||
|
|
423fd3f429 | ||
|
|
257def3b53 | ||
|
|
97a5687f2e | ||
|
|
d2d8e449ae | ||
|
|
a2d3a367d1 | ||
|
|
dc8a62313c | ||
|
|
6ec582a834 | ||
|
|
b3834bf559 | ||
|
|
b234ead964 | ||
|
|
a756a0484f | ||
|
|
34e816ff7c | ||
|
|
841262341a | ||
|
|
142411c0e5 | ||
|
|
e424dac826 | ||
|
|
6acfc74a65 | ||
|
|
aeb3a7be64 | ||
|
|
ea997b41de | ||
|
|
7d04d8861f | ||
|
|
bf9cf2bf96 | ||
|
|
2346708ab1 | ||
|
|
25c216e2b6 | ||
|
|
af0091c370 | ||
|
|
2c4ac8b286 | ||
|
|
4c6187e18d | ||
|
|
b75dadf00e | ||
|
|
e067a17f53 | ||
|
|
9643e08232 | ||
|
|
06792019f8 | ||
|
|
f952d6ed3e | ||
|
|
f0b8118e00 | ||
|
|
e27cda5779 | ||
|
|
f25c6bd8f5 | ||
|
|
64f201f4a3 | ||
|
|
032159149c | ||
|
|
2d6ab078be | ||
|
|
479df08d63 | ||
|
|
0a4bcfa7a3 | ||
|
|
248e485ff0 | ||
|
|
51995681e3 | ||
|
|
92f33c8cff | ||
|
|
e268a581ce | ||
|
|
f42351e94f | ||
|
|
9ed353ebe3 | ||
|
|
8a00828232 | ||
|
|
01cde545ce | ||
|
|
6db280907a | ||
|
|
ca946f2cba | ||
|
|
7da172cfad | ||
|
|
de0e06155b | ||
|
|
44461b1ec7 | ||
|
|
833708e38a | ||
|
|
7f5274b34d | ||
|
|
f6556b579f | ||
|
|
3cd7577b06 | ||
|
|
e6223a36b7 | ||
|
|
60a37b65d6 | ||
|
|
5feb271aed | ||
|
|
cb6fcdd676 | ||
|
|
129c230c72 | ||
|
|
965a8487b9 | ||
|
|
1ce5f37fc4 | ||
|
|
2e60b84c83 | ||
|
|
61b215436b | ||
|
|
101bb022c9 | ||
|
|
35877a1b2e | ||
|
|
df9c7f9cf7 | ||
|
|
d85737bfb8 | ||
|
|
10f48af304 | ||
|
|
2ec9fd14c0 | ||
|
|
0c22fc137b | ||
|
|
cb012a4d82 | ||
|
|
d44cd0b2ed | ||
|
|
60e1a161ca | ||
|
|
1fd773f726 | ||
|
|
2e110dcafd | ||
|
|
59a75aac9d | ||
|
|
7666186f83 | ||
|
|
db3322c3d8 | ||
|
|
9dac3ef988 | ||
|
|
044cbbe698 | ||
|
|
62a141ff6c | ||
|
|
930b3dd1d3 | ||
|
|
1ca8bbf6e0 | ||
|
|
f1f173715e | ||
|
|
b65a913d4e | ||
|
|
349d07c691 | ||
|
|
42febeb654 | ||
|
|
864d2ed3ba | ||
|
|
2726c60608 | ||
|
|
b218a75ea8 | ||
|
|
0895f74b13 | ||
|
|
4693820820 | ||
|
|
0c6a8d7d15 | ||
|
|
62599c22ea | ||
|
|
5ee5d956f0 | ||
|
|
8feb717d91 | ||
|
|
db0888453a | ||
|
|
7b10b20b9e | ||
|
|
613685aebb | ||
|
|
3d4f3a9a77 | ||
|
|
816cb679c7 | ||
|
|
8e048cbddc | ||
|
|
2f35c08afd | ||
|
|
64b0435c86 | ||
|
|
3468baaa84 | ||
|
|
cf76ca4f98 | ||
|
|
4398eb1fdb | ||
|
|
48968bac78 | ||
|
|
e3edd49800 | ||
|
|
abad4b4eb7 | ||
|
|
df3edccd7f | ||
|
|
5a69d9ba20 | ||
|
|
a2be0e2fca | ||
|
|
97ad3cbee2 | ||
|
|
2953a487d2 | ||
|
|
e2939cd8ba | ||
|
|
7fae44790c | ||
|
|
504a64aa63 | ||
|
|
f4083b7014 | ||
|
|
8736c00fd5 | ||
|
|
3f8ab7d905 | ||
|
|
caa9d10b1b | ||
|
|
9a87188318 | ||
|
|
1924270e4e | ||
|
|
d538f6f7a8 | ||
|
|
f9e5d0eb2e | ||
|
|
5a2b0f4f4b | ||
|
|
e496a41a39 | ||
|
|
61758622ef | ||
|
|
531ad3342b | ||
|
|
df490ae04b | ||
|
|
7af750b9e7 | ||
|
|
90fc6e8726 | ||
|
|
4dd501a2f8 | ||
|
|
64c61dd178 | ||
|
|
5d4efc76de | ||
|
|
21ac153d5b | ||
|
|
fa8f843154 | ||
|
|
a72de0a62f | ||
|
|
f3db495bd2 | ||
|
|
07b0c2d3c5 | ||
|
|
31cf8772c2 | ||
|
|
bb9fb0b327 | ||
|
|
146034c78b | ||
|
|
30ac29fd6c | ||
|
|
4eabe21d68 | ||
|
|
a0f5b5dfd5 | ||
|
|
70228912be | ||
|
|
b92213b356 | ||
|
|
86e293498f | ||
|
|
2c4762142e | ||
|
|
14911799b7 | ||
|
|
ed2063df17 | ||
|
|
635b17dd7f | ||
|
|
245d63c7b2 | ||
|
|
cea6b1cff2 | ||
|
|
7a038e6af1 | ||
|
|
b6d901fadf | ||
|
|
c32fa1bccd | ||
|
|
b8d23131b7 | ||
|
|
46b21f75b5 | ||
|
|
19b700b676 | ||
|
|
c6c05193c6 | ||
|
|
39744d6248 | ||
|
|
72d1680946 | ||
|
|
abfc3b0205 | ||
|
|
19638326dd | ||
|
|
13f650b28b | ||
|
|
1d7ac301ab | ||
|
|
a7110576d2 | ||
|
|
0ccd236307 | ||
|
|
2f0a526245 | ||
|
|
69698b312d | ||
|
|
907cf86f6a | ||
|
|
a466726b70 | ||
|
|
3621428093 | ||
|
|
55bfb4d997 | ||
|
|
f62d47e2fb | ||
|
|
0ada050eff | ||
|
|
82382cf43f | ||
|
|
8503dffa4e | ||
|
|
aff426829d | ||
|
|
55337cde13 | ||
|
|
c3be0e92d2 | ||
|
|
d21afa5f6d | ||
|
|
3f502dba81 | ||
|
|
49f4067bc5 | ||
|
|
2bac9307c6 | ||
|
|
7225b1908b | ||
|
|
da82b7f536 | ||
|
|
289337b5e4 | ||
|
|
f39c4d2c58 | ||
|
|
149f64ebb6 | ||
|
|
186bb7d14c | ||
|
|
56e76f88d8 | ||
|
|
a414a5b3a9 | ||
|
|
72e3383e75 | ||
|
|
80d3538d87 | ||
|
|
2476b9a106 | ||
|
|
58184cd726 | ||
|
|
77ff5dbbd0 | ||
|
|
8ed4121f38 | ||
|
|
8b9e536f14 | ||
|
|
980afbc5ac | ||
|
|
8e4a63d224 | ||
|
|
7d748ec7e6 | ||
|
|
d0c272e1e5 | ||
|
|
e750315764 | ||
|
|
4a2a119ef1 | ||
|
|
8799772634 | ||
|
|
90b444a097 | ||
|
|
21652658c3 | ||
|
|
ea5d4d5e2e | ||
|
|
9f9f43751f | ||
|
|
a49346bda4 | ||
|
|
0c0a018cd2 | ||
|
|
5c87d96a51 | ||
|
|
775d012079 | ||
|
|
819af8b7b8 | ||
|
|
bd344d56ef | ||
|
|
c2b3b439e6 | ||
|
|
78f7a68c1e | ||
|
|
2e998dbb26 | ||
|
|
e0344787f1 | ||
|
|
49aae062ef | ||
|
|
cb6619594a | ||
|
|
4880946515 | ||
|
|
c0fabcedd2 | ||
|
|
e860f715d8 | ||
|
|
7fd213ccab | ||
|
|
b8dbcd4262 | ||
|
|
064fea3a00 | ||
|
|
1c3065e432 | ||
|
|
3fb719bf15 | ||
|
|
cb9bb23e2d | ||
|
|
95f9b43395 | ||
|
|
298f4dcd61 | ||
|
|
0b38bcaa21 | ||
|
|
f3cb95960c | ||
|
|
c4b1df8a6d | ||
|
|
9f53e8b2aa | ||
|
|
b353aa90f4 | ||
|
|
1014f6c961 | ||
|
|
5cf90208d8 | ||
|
|
150c87b456 | ||
|
|
1f0a840396 | ||
|
|
e019c288de | ||
|
|
1e39e39a04 | ||
|
|
60651361e2 | ||
|
|
d11044f638 | ||
|
|
805e5729e1 | ||
|
|
a7e1311ad5 | ||
|
|
697b4c4924 | ||
|
|
7d9857f97e | ||
|
|
1e51b6b8e8 | ||
|
|
7503e1b2c1 | ||
|
|
e235aa58ba | ||
|
|
f65c5b303e | ||
|
|
2b24b8adff | ||
|
|
caad5633d0 | ||
|
|
9258f3ed06 | ||
|
|
e6af7a73d5 | ||
|
|
496981efd0 | ||
|
|
a06f5fea12 | ||
|
|
aa6d311fa8 | ||
|
|
deb87dceed | ||
|
|
6ea3f77b62 | ||
|
|
d739ef3758 | ||
|
|
33b4b3299e | ||
|
|
5b60b9e915 | ||
|
|
f06feffb9e | ||
|
|
ecb42207f1 | ||
|
|
8b816e9566 | ||
|
|
5b10aed601 | ||
|
|
d37bbd85a5 | ||
|
|
5089dea7a1 | ||
|
|
9a5b929877 | ||
|
|
48946810be | ||
|
|
25f12aa49e | ||
|
|
f4f64fefcc | ||
|
|
8abed86b7d | ||
|
|
784995728c | ||
|
|
5233bbb234 | ||
|
|
27b5de93ea | ||
|
|
33063f992a | ||
|
|
787ccb4fc1 | ||
|
|
ae983473b3 | ||
|
|
8cdc625133 | ||
|
|
c7f4781137 | ||
|
|
d2072fe777 | ||
|
|
e33ab7366d | ||
|
|
e5c5014812 | ||
|
|
3c4583c2b3 | ||
|
|
7b83e2fde7 | ||
|
|
fe0e6a5328 | ||
|
|
82a3d793d1 | ||
|
|
0fa096ea2f | ||
|
|
e542d3dea4 | ||
|
|
d00eed7894 | ||
|
|
887da31598 | ||
|
|
963e198a74 | ||
|
|
b9a0911e29 | ||
|
|
c727b98b17 | ||
|
|
3fbbeec573 | ||
|
|
7e64da504c | ||
|
|
46ce943e10 | ||
|
|
b8f38f64d8 | ||
|
|
50b86007cf | ||
|
|
588d715e7a | ||
|
|
3337869c0d | ||
|
|
fd0d72362f | ||
|
|
72f4b72755 | ||
|
|
ec30a4e7e8 | ||
|
|
ca22942ab1 | ||
|
|
da10a54d7e | ||
|
|
057ca2cec4 | ||
|
|
8f9de65318 | ||
|
|
0cb22da810 | ||
|
|
10d5d7b220 | ||
|
|
893ae6ee91 | ||
|
|
73adc3339d | ||
|
|
4ac09a925d | ||
|
|
7bf4ac9984 | ||
|
|
e84104271b | ||
|
|
89194e0c20 | ||
|
|
95f3931dd0 | ||
|
|
f39b6a5e9e | ||
|
|
b381db1714 | ||
|
|
3b589c9fbf | ||
|
|
3b55ec4921 | ||
|
|
041d88e670 | ||
|
|
13538191c9 | ||
|
|
df7696a512 | ||
|
|
fce0dfe746 | ||
|
|
66fa5c2cf0 | ||
|
|
7563f235cb | ||
|
|
c045e880a7 | ||
|
|
b972b17515 | ||
|
|
990191d13e | ||
|
|
7a63210bdf | ||
|
|
bddb528a2d | ||
|
|
3863dd10f6 | ||
|
|
e0aaf9e603 | ||
|
|
c239f4eb10 | ||
|
|
91f6395509 | ||
|
|
9a59eb35a8 | ||
|
|
073151cf14 | ||
|
|
b0596af18d | ||
|
|
3d62d4edde | ||
|
|
2d333a6e0a | ||
|
|
f972ef27c7 | ||
|
|
4d95e21316 | ||
|
|
9566b32558 | ||
|
|
b1af1bd876 | ||
|
|
7b9be43d4a | ||
|
|
07b17b3c82 | ||
|
|
f19629afde | ||
|
|
47ceddb1f1 | ||
|
|
513c0fb432 | ||
|
|
2178d512c6 | ||
|
|
a29f658661 | ||
|
|
af81f53160 | ||
|
|
dc7ae72bd4 | ||
|
|
beeb8bbe30 | ||
|
|
61e5554f20 | ||
|
|
6afd742aa8 | ||
|
|
954bd1f3ca | ||
|
|
17bb55655e | ||
|
|
ca62712878 | ||
|
|
8c07bd7219 | ||
|
|
b70796ad28 | ||
|
|
568a3d4f52 | ||
|
|
fa61cdc6e5 | ||
|
|
988464db85 | ||
|
|
1a67b485ae | ||
|
|
09141314d8 | ||
|
|
97a48ea9c6 | ||
|
|
cd93bc956a | ||
|
|
010ab1fdd6 | ||
|
|
09a22ce21a | ||
|
|
3ee6a35b73 | ||
|
|
e35b19e16f | ||
|
|
e1e54fe248 | ||
|
|
2f5842133e | ||
|
|
1ac2d630c7 | ||
|
|
0f04094533 | ||
|
|
ab1854f8d2 | ||
|
|
61a9d8e80f | ||
|
|
96f3baf3c4 | ||
|
|
2456beb494 | ||
|
|
26fa1a0ed4 | ||
|
|
b10912cb75 | ||
|
|
1208857c5f | ||
|
|
688d2e5090 | ||
|
|
681b873eb0 | ||
|
|
8b34b63990 | ||
|
|
d9465cfdd6 | ||
|
|
f01ec9da2e | ||
|
|
cf5171c237 | ||
|
|
1b5e4ebc33 | ||
|
|
3c4927f918 | ||
|
|
50af00520e | ||
|
|
07ccf029a9 | ||
|
|
a8b79ea8e3 | ||
|
|
ce111ec03d | ||
|
|
796a3ed4ba | ||
|
|
f2c4574e96 | ||
|
|
0034625198 | ||
|
|
4ff8503acc | ||
|
|
80b3a1d1a6 | ||
|
|
3339a85123 | ||
|
|
5d3a878cf6 | ||
|
|
dc4b710ef2 | ||
|
|
e77ab0bc28 | ||
|
|
ce9c7297c7 | ||
|
|
9d0f397c04 | ||
|
|
63b9bd3c5b | ||
|
|
60b1646687 | ||
|
|
b108b8aead | ||
|
|
60476e59d6 | ||
|
|
ea8c3c6891 | ||
|
|
6bd864f29a | ||
|
|
85719226d8 | ||
|
|
7549883f14 | ||
|
|
ac4c35aae4 | ||
|
|
1930e95c15 | ||
|
|
9b74af1734 | ||
|
|
ab8d37f040 | ||
|
|
cb57375f63 | ||
|
|
db08f98ae0 | ||
|
|
2d26bd2c4e | ||
|
|
44fe5ab255 | ||
|
|
aa6227ea1a | ||
|
|
5d72639fba | ||
|
|
33b5067e76 | ||
|
|
4d003b6db2 | ||
|
|
fefcc6ab6f | ||
|
|
5bbd35d272 | ||
|
|
76ff3fba23 | ||
|
|
01176c845b | ||
|
|
10de2f216f | ||
|
|
a2b286e9aa | ||
|
|
6ec3dfa790 | ||
|
|
219969db91 | ||
|
|
6b4ccf7604 | ||
|
|
22a9ef8659 | ||
|
|
8efeaf1c4c | ||
|
|
96db966de7 | ||
|
|
84af84dcd5 | ||
|
|
bc361d1141 | ||
|
|
553005f3bf | ||
|
|
a15d4645b9 | ||
|
|
6ebd9994c3 | ||
|
|
45bc57e54e | ||
|
|
277fa8b316 | ||
|
|
a1186ae347 | ||
|
|
ed5ee2f732 | ||
|
|
4ea13e9883 | ||
|
|
2b970ea49a | ||
|
|
24c677094a | ||
|
|
90c8201519 | ||
|
|
488b415f88 | ||
|
|
d7b0576269 | ||
|
|
4f0d4a6b3c | ||
|
|
bcf0c93e61 | ||
|
|
ce32799f6c | ||
|
|
59e98d8a52 | ||
|
|
16ae29b3b6 | ||
|
|
3b6dff4092 | ||
|
|
afea23c7c3 | ||
|
|
528e14e235 | ||
|
|
ba42698e7e | ||
|
|
16736e34eb | ||
|
|
575998c0e1 | ||
|
|
7b07c8a810 | ||
|
|
78dbc0dde4 | ||
|
|
8f317e6a62 | ||
|
|
6544acd967 | ||
|
|
e50bbff365 | ||
|
|
8d678fa3f5 | ||
|
|
811f64f461 |
431 changed files with 21079 additions and 2120 deletions
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: [maddalax]
|
||||
52
.github/workflows/release-auth-example.yml
vendored
Normal file
52
.github/workflows/release-auth-example.yml
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
name: Build and Deploy htmgo auth example
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch: # Trigger on manual workflow_dispatch
|
||||
push:
|
||||
branches:
|
||||
- master # Trigger on pushes to master
|
||||
paths:
|
||||
- 'examples/simple-auth/**' # Trigger only if files in this directory change
|
||||
- "framework-ui/**"
|
||||
- "cli/**"
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get short commit hash
|
||||
id: vars
|
||||
run: echo "::set-output name=short_sha::$(echo $GITHUB_SHA | cut -c1-7)"
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
cd ./examples/simple-auth && docker build -t ghcr.io/${{ github.repository_owner }}/simple-auth:${{ steps.vars.outputs.short_sha }} .
|
||||
|
||||
- name: Tag as latest Docker image
|
||||
run: |
|
||||
docker tag ghcr.io/${{ github.repository_owner }}/simple-auth:${{ steps.vars.outputs.short_sha }} ghcr.io/${{ github.repository_owner }}/simple-auth:latest
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
run: echo "${{ secrets.CR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Push Docker image
|
||||
run: |
|
||||
docker push ghcr.io/${{ github.repository_owner }}/simple-auth:latest
|
||||
50
.github/workflows/release-chat-example.yml
vendored
Normal file
50
.github/workflows/release-chat-example.yml
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
name: Build and Deploy htmgo.dev chat example
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch: # Trigger on manual workflow_dispatch
|
||||
push:
|
||||
branches:
|
||||
- master # Trigger on pushes to master
|
||||
paths:
|
||||
- 'examples/chat/**' # Trigger only if files in this directory change
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get short commit hash
|
||||
id: vars
|
||||
run: echo "::set-output name=short_sha::$(echo $GITHUB_SHA | cut -c1-7)"
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
cd ./examples/chat && docker build -t ghcr.io/${{ github.repository_owner }}/htmgo-chat-example:${{ steps.vars.outputs.short_sha }} .
|
||||
|
||||
- name: Tag as latest Docker image
|
||||
run: |
|
||||
docker tag ghcr.io/${{ github.repository_owner }}/htmgo-chat-example:${{ steps.vars.outputs.short_sha }} ghcr.io/${{ github.repository_owner }}/htmgo-chat-example:latest
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
run: echo "${{ secrets.CR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Push Docker image
|
||||
run: |
|
||||
docker push ghcr.io/${{ github.repository_owner }}/htmgo-chat-example:latest
|
||||
52
.github/workflows/release-hn-clone.yml
vendored
Normal file
52
.github/workflows/release-hn-clone.yml
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
name: Build and Deploy htmgo hackernews clone
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch: # Trigger on manual workflow_dispatch
|
||||
push:
|
||||
branches:
|
||||
- master # Trigger on pushes to master
|
||||
paths:
|
||||
- 'examples/hackernews/**' # Trigger only if files in this directory change
|
||||
- "framework-ui/**"
|
||||
- "cli/**"
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get short commit hash
|
||||
id: vars
|
||||
run: echo "::set-output name=short_sha::$(echo $GITHUB_SHA | cut -c1-7)"
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
cd ./examples/hackernews && docker build -t ghcr.io/${{ github.repository_owner }}/hackernews:${{ steps.vars.outputs.short_sha }} .
|
||||
|
||||
- name: Tag as latest Docker image
|
||||
run: |
|
||||
docker tag ghcr.io/${{ github.repository_owner }}/hackernews:${{ steps.vars.outputs.short_sha }} ghcr.io/${{ github.repository_owner }}/hackernews:latest
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
run: echo "${{ secrets.CR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Push Docker image
|
||||
run: |
|
||||
docker push ghcr.io/${{ github.repository_owner }}/hackernews:latest
|
||||
8
.github/workflows/release-site.yml
vendored
8
.github/workflows/release-site.yml
vendored
|
|
@ -1,12 +1,18 @@
|
|||
name: Build and Deploy htmgo.dev
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch: # Trigger on manual workflow_dispatch
|
||||
push:
|
||||
branches:
|
||||
- master # Trigger on pushes to master
|
||||
paths:
|
||||
- 'htmgo-site/**' # Trigger only if files in this directory change
|
||||
- "framework-ui/**"
|
||||
- "cli/**"
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
|
|
@ -43,4 +49,4 @@ jobs:
|
|||
|
||||
- name: Push Docker image
|
||||
run: |
|
||||
docker push ghcr.io/${{ github.repository_owner }}/htmgo-site:latest
|
||||
docker push ghcr.io/${{ github.repository_owner }}/htmgo-site:latest
|
||||
|
|
|
|||
50
.github/workflows/release-starter-template.yml
vendored
Normal file
50
.github/workflows/release-starter-template.yml
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
name: Build and Deploy starter template
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch: # Trigger on manual workflow_dispatch
|
||||
push:
|
||||
branches:
|
||||
- master # Trigger on pushes to master
|
||||
paths:
|
||||
- 'templates/starter/**' # Trigger only if files in this directory change
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get short commit hash
|
||||
id: vars
|
||||
run: echo "::set-output name=short_sha::$(echo $GITHUB_SHA | cut -c1-7)"
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
cd ./templates/starter && docker build -t ghcr.io/${{ github.repository_owner }}/starter-template:${{ steps.vars.outputs.short_sha }} .
|
||||
|
||||
- name: Tag as latest Docker image
|
||||
run: |
|
||||
docker tag ghcr.io/${{ github.repository_owner }}/starter-template:${{ steps.vars.outputs.short_sha }} ghcr.io/${{ github.repository_owner }}/starter-template:latest
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
run: echo "${{ secrets.CR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Push Docker image
|
||||
run: |
|
||||
docker push ghcr.io/${{ github.repository_owner }}/starter-template:latest
|
||||
6
.github/workflows/release-todo-example.yml
vendored
6
.github/workflows/release-todo-example.yml
vendored
|
|
@ -1,6 +1,10 @@
|
|||
name: Build and Deploy htmgo.dev todo example
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch: # Trigger on manual workflow_dispatch
|
||||
push:
|
||||
branches:
|
||||
|
|
@ -43,4 +47,4 @@ jobs:
|
|||
|
||||
- name: Push Docker image
|
||||
run: |
|
||||
docker push ghcr.io/${{ github.repository_owner }}/htmgo-todo-example:latest
|
||||
docker push ghcr.io/${{ github.repository_owner }}/htmgo-todo-example:latest
|
||||
|
|
|
|||
48
.github/workflows/release-ws-test.yml
vendored
Normal file
48
.github/workflows/release-ws-test.yml
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
name: Build and Deploy ws-test
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- ws-testing
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get short commit hash
|
||||
id: vars
|
||||
run: echo "::set-output name=short_sha::$(echo $GITHUB_SHA | cut -c1-7)"
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
cd ./examples/ws-example && docker build -t ghcr.io/${{ github.repository_owner }}/ws-example:${{ steps.vars.outputs.short_sha }} .
|
||||
|
||||
- name: Tag as latest Docker image
|
||||
run: |
|
||||
docker tag ghcr.io/${{ github.repository_owner }}/ws-example:${{ steps.vars.outputs.short_sha }} ghcr.io/${{ github.repository_owner }}/ws-example:latest
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
run: echo "${{ secrets.CR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Push Docker image
|
||||
run: |
|
||||
docker push ghcr.io/${{ github.repository_owner }}/ws-example:latest
|
||||
33
.github/workflows/run-cli-tests.yml
vendored
Normal file
33
.github/workflows/run-cli-tests.yml
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
name: CLI Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- '**' # Runs on any pull request to any branch
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.23' # Specify the Go version you need
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd ./cli/htmgo && go mod download
|
||||
|
||||
- name: Run Go tests
|
||||
run: cd ./cli/htmgo/tasks/astgen && go test ./... -coverprofile=coverage.txt
|
||||
|
||||
- name: Upload results to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
9
.github/workflows/run-framework-tests.yml
vendored
9
.github/workflows/run-framework-tests.yml
vendored
|
|
@ -3,7 +3,7 @@ name: Framework Tests
|
|||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**' # Runs on any branch push
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- '**' # Runs on any pull request to any branch
|
||||
|
|
@ -25,4 +25,9 @@ jobs:
|
|||
run: cd ./framework && go mod download
|
||||
|
||||
- name: Run Go tests
|
||||
run: cd ./framework && go test ./...
|
||||
run: cd ./framework && go test ./... -coverprofile=coverage.txt
|
||||
|
||||
- name: Upload results to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
|
|
|||
39
.github/workflows/update-framework-dep.yml
vendored
Normal file
39
.github/workflows/update-framework-dep.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
name: Update HTMGO Framework Dependency
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Trigger on manual workflow_dispatch
|
||||
push:
|
||||
branches:
|
||||
- master # Trigger on pushes to master
|
||||
paths:
|
||||
- 'framework/**'
|
||||
- 'tools/html-to-htmgo/**'
|
||||
|
||||
jobs:
|
||||
update-htmgo-dep:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
persist-credentials: false # Necessary to avoid using the runner's credentials for commit
|
||||
fetch-depth: 0 # Full history for committing back changes
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: ">=1.20"
|
||||
|
||||
- name: Run update-htmgo-dep.go script
|
||||
run: go run tools/update-htmgo-dep.go
|
||||
|
||||
- name: Commit changes
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add .
|
||||
git commit -m "Auto-update HTMGO framework version"
|
||||
git push https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git master
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
65
.github/workflows/verify-installer-works.yml
vendored
Normal file
65
.github/workflows/verify-installer-works.yml
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
name: Build and Verify Installer Works
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
# Step 1: Checkout the repository
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Step 2: Set up Go
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '>=1.20'
|
||||
|
||||
# Step 3: Install htmgo CLI
|
||||
- name: Install htmgo CLI
|
||||
run: |
|
||||
GOPRIVATE=github.com/maddalax GOPROXY=direct go install github.com/maddalax/htmgo/cli/htmgo@latest
|
||||
|
||||
# Step 4: Generate template using htmgo
|
||||
- name: Generate myapp template
|
||||
run: |
|
||||
htmgo template myapp
|
||||
|
||||
# Step 5: Build the app
|
||||
- name: Build myapp
|
||||
run: |
|
||||
cd myapp
|
||||
htmgo build
|
||||
|
||||
# Step 6: Verify that the dist directory exists
|
||||
- name: Verify build output
|
||||
run: |
|
||||
if [ ! -d "./myapp/dist" ]; then
|
||||
echo "Build directory ./dist/myapp does not exist"
|
||||
exit 1
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
|
||||
# Step 7: Start the server
|
||||
- name: Start myapp server
|
||||
run: |
|
||||
nohup ./myapp/dist/myapp &
|
||||
|
||||
# Step 8: Wait for server to start
|
||||
- name: Wait for server startup
|
||||
run: sleep 5
|
||||
|
||||
# Step 9: Send curl request to verify the server is running
|
||||
- name: Test server with curl
|
||||
run: |
|
||||
curl --fail http://localhost:3000 || exit 1
|
||||
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
maddox@htmgo.dev.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024 maddalax
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
19
README.md
19
README.md
|
|
@ -1,14 +1,20 @@
|
|||
> [!WARNING]
|
||||
> htmgo is in alpha release. Please report any issues on GitHub.
|
||||
|
||||
## **htmgo**
|
||||
|
||||
### build simple and scalable systems with go + htmx
|
||||
|
||||
-------
|
||||
[](https://goreportcard.com/report/github.com/maddalax/htmgo)
|
||||

|
||||
[](https://htmgo.dev/docs)
|
||||
[](https://codecov.io/github/maddalax/htmgo)
|
||||
[](https://htmgo.dev/discord)
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
<sup>looking for a python version? check out: https://fastht.ml</sup>
|
||||
|
||||
**introduction:**
|
||||
|
||||
htmgo is a lightweight pure go way to build interactive websites / web applications using go & htmx.
|
||||
|
|
@ -33,9 +39,12 @@ func IndexPage(ctx *h.RequestContext) *h.Page {
|
|||
2. live reload (rebuilds css, go, ent schema, and routes upon change)
|
||||
3. automatic page and partial registration based on file path
|
||||
4. built in tailwindcss support, no need to configure anything by default
|
||||
5. plugin architecture to include optional plugins to streamline development, such as http://entgo.io
|
||||
6. custom [htmx extensions](https://github.com/maddalax/htmgo/tree/b610aefa36e648b98a13823a6f8d87566120cfcc/framework/assets/js/htmxextensions) to reduce boilerplate with common tasks
|
||||
5. custom [htmx extensions](https://github.com/maddalax/htmgo/tree/b610aefa36e648b98a13823a6f8d87566120cfcc/framework/assets/js/htmxextensions) to reduce boilerplate with common tasks
|
||||
|
||||
**get started:**
|
||||
|
||||
View documentation on [htmgo.dev](https://htmgo.dev/docs).
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#maddalax/htmgo&Date)
|
||||
|
|
|
|||
|
|
@ -3,10 +3,25 @@ module github.com/maddalax/htmgo/cli/htmgo
|
|||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/dave/jennifer v1.7.1
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
|
||||
github.com/maddalax/htmgo/tools/html-to-htmgo v0.0.0-20250703190716-06f01b3d7c1b
|
||||
github.com/stretchr/testify v1.9.0
|
||||
golang.org/x/mod v0.21.0
|
||||
golang.org/x/net v0.29.0
|
||||
golang.org/x/sys v0.25.0
|
||||
golang.org/x/sys v0.26.0
|
||||
golang.org/x/tools v0.25.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bmatcuk/doublestar/v4 v4.7.1
|
||||
github.com/go-chi/chi/v5 v5.1.0 // indirect
|
||||
golang.org/x/net v0.30.0 // indirect
|
||||
golang.org/x/text v0.19.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
|
|||
32
cli/htmgo/go.sum
Normal file
32
cli/htmgo/go.sum
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q=
|
||||
github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4=
|
||||
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
|
||||
github.com/maddalax/htmgo/tools/html-to-htmgo v0.0.0-20250703190716-06f01b3d7c1b h1:jvfp35fig2TzBjAgw82fe8+7cvaLX9EbipZUlj8FDDY=
|
||||
github.com/maddalax/htmgo/tools/html-to-htmgo v0.0.0-20250703190716-06f01b3d7c1b/go.mod h1:FraJsj3NRuLBQDk83ZVa+psbNRNLe+rajVtVhYMEme4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
|
||||
golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/dave/jennifer/jen"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Example HTML input
|
||||
htmlData := `
|
||||
<body><nav class="flex gap-4 items-center p-4 text-slate-600 "><a href="/" class="cursor-pointer hover:text-blue-400 ">Home</a><a class="cursor-pointer hover:text-blue-400 " href="/news">News</a><a href="/patients" class="cursor-pointer hover:text-blue-400 ">Patients</a></nav><div id="active-modal"></div><div class="flex flex-col gap-2 bg-white h-full "><div class="flex flex-col p-4 w-full "><div><div class="flex justify-between items-center "><p class="text-lg font-bold ">Manage Patients</p><button hx-target="#active-modal" type="button" id="add-patient" class="flex gap-1 items-center border p-4 rounded cursor-hover bg-blue-700 text-white rounded p-2 h-12 " hx-get="htmgo/partials/patient.AddPatientSheet">Add Patient</button></div><div hx-get="htmgo/partials/patient.List" hx-trigger="load, patient-added from:body" class=""><div class="mt-8" id="patient-list"><div class="flex flex-col gap-2 rounded p-4 bg-red-100 "><p>Name: Sydne</p><p>Reason for visit: arm hurts</p></div></div></div></div></div></div><div hx-get="/livereload" hx-trigger="every 200ms" class=""></div></body>
|
||||
`
|
||||
|
||||
// Parse the HTML
|
||||
doc, err := html.Parse(bytes.NewReader([]byte(htmlData)))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a new Jennifer file
|
||||
f := jen.NewFile("main")
|
||||
|
||||
// Generate Jennifer code for the parsed HTML tree
|
||||
generatedCode := processNode(doc.FirstChild)
|
||||
|
||||
// Add the generated code to the file
|
||||
f.Func().Id("Render").Params().Block(generatedCode...)
|
||||
|
||||
// Render the generated code
|
||||
var buf bytes.Buffer
|
||||
err = f.Render(&buf)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
//// Format the generated code
|
||||
//formattedCode, err := format.Source(buf.Bytes())
|
||||
//if err != nil {
|
||||
// log.Fatal(err)
|
||||
//}
|
||||
|
||||
// Output the formatted code
|
||||
fmt.Println(string(buf.Bytes()))
|
||||
}
|
||||
|
||||
// Recursively process the HTML nodes and generate Jennifer code
|
||||
func processNode(n *html.Node) []jen.Code {
|
||||
var code []jen.Code
|
||||
|
||||
// Only process element nodes
|
||||
if n.Type == html.ElementNode {
|
||||
// Create a dynamic method call based on the tag name
|
||||
tagMethod := strings.Title(n.Data) // Capitalize the first letter of the tag
|
||||
|
||||
// Add dynamic method call for the tag (e.g., h.Div(), h.Button(), etc.)
|
||||
code = append(code, jen.Id("h").Dot(tagMethod).Call(mergeArgs(n)...))
|
||||
}
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
// Merge attributes and children into a single slice for Call()
|
||||
func mergeArgs(n *html.Node) []jen.Code {
|
||||
// Process attributes
|
||||
attrs := processAttributes(n.Attr)
|
||||
|
||||
// Process children
|
||||
children := processChildren(n)
|
||||
|
||||
// Combine attributes and children into one slice
|
||||
return append(attrs, children...)
|
||||
}
|
||||
|
||||
// Process child nodes of a given HTML node
|
||||
func processChildren(n *html.Node) []jen.Code {
|
||||
var children []jen.Code
|
||||
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
children = append(children, processNode(c)...)
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
func FormatFieldName(name string) string {
|
||||
split := strings.Split(name, "_")
|
||||
if strings.Contains(name, "-") {
|
||||
split = strings.Split(name, "-")
|
||||
}
|
||||
parts := make([]string, 0)
|
||||
for _, s := range split {
|
||||
parts = append(parts, PascalCase(s))
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func PascalCase(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
// Convert the first rune (character) to uppercase and concatenate with the rest of the string
|
||||
return strings.ToUpper(string(s[0])) + s[1:]
|
||||
}
|
||||
|
||||
// Process the attributes of an HTML node and return Jennifer code
|
||||
func processAttributes(attrs []html.Attribute) []jen.Code {
|
||||
var args []jen.Code
|
||||
for _, attr := range attrs {
|
||||
// Dynamically handle all attributes
|
||||
attrMethod := FormatFieldName(attr.Key) // E.g., convert "data-role" to "DataRole"
|
||||
args = append(args, jen.Id("h").Dot(attrMethod).Call(jen.Lit(attr.Val)))
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package main
|
||||
package internal
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
|
||||
"github.com/maddalax/htmgo/framework/config"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
|
@ -17,6 +18,22 @@ func HasFileFromRoot(file string) bool {
|
|||
return err == nil
|
||||
}
|
||||
|
||||
func GetConfig() *config.ProjectConfig {
|
||||
return config.FromConfigFile(process.GetWorkingDir())
|
||||
}
|
||||
|
||||
func CreateHtmgoDir() {
|
||||
if !HasFileFromRoot("__htmgo") {
|
||||
CreateDirFromRoot("__htmgo")
|
||||
}
|
||||
}
|
||||
|
||||
func CreateDirFromRoot(dir string) error {
|
||||
cwd := process.GetWorkingDir()
|
||||
path := filepath.Join(cwd, dir)
|
||||
return os.MkdirAll(path, 0700)
|
||||
}
|
||||
|
||||
func CopyDir(srcDir, dstDir string, predicate func(path string, exists bool) bool) error {
|
||||
// Walk the source directory tree.
|
||||
return filepath.Walk(srcDir, func(srcPath string, info os.FileInfo, err error) error {
|
||||
|
|
|
|||
33
cli/htmgo/internal/dirutil/glob.go
Normal file
33
cli/htmgo/internal/dirutil/glob.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package dirutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func matchesAny(patterns []string, path string) bool {
|
||||
for _, pattern := range patterns {
|
||||
matched, err :=
|
||||
doublestar.Match(strings.ReplaceAll(pattern, `\`, "/"), strings.ReplaceAll(path, `\`, "/"))
|
||||
if err != nil {
|
||||
fmt.Printf("Error matching pattern: %v\n", err)
|
||||
return false
|
||||
}
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsGlobExclude(path string, excludePatterns []string) bool {
|
||||
return matchesAny(excludePatterns, path)
|
||||
}
|
||||
|
||||
func IsGlobMatch(path string, patterns []string, excludePatterns []string) bool {
|
||||
if matchesAny(excludePatterns, path) {
|
||||
return false
|
||||
}
|
||||
return matchesAny(patterns, path)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package main
|
||||
package internal
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
func getLogLevel() slog.Level {
|
||||
func GetLogLevel() slog.Level {
|
||||
// Get the log level from the environment variable
|
||||
logLevel := os.Getenv("LOG_LEVEL")
|
||||
switch strings.ToUpper(logLevel) {
|
||||
|
|
@ -4,10 +4,12 @@ import (
|
|||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/internal"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/astgen"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/copyassets"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/css"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/downloadtemplate"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/formatter"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/reloader"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/run"
|
||||
|
|
@ -16,11 +18,13 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
const version = "1.0.6"
|
||||
|
||||
func main() {
|
||||
done := RegisterSignals()
|
||||
needsSignals := true
|
||||
|
||||
commandMap := make(map[string]*flag.FlagSet)
|
||||
commands := []string{"template", "run", "watch", "build", "setup", "css", "schema", "generate"}
|
||||
commands := []string{"template", "run", "watch", "build", "setup", "css", "schema", "generate", "format", "version"}
|
||||
|
||||
for _, command := range commands {
|
||||
commandMap[command] = flag.NewFlagSet(command, flag.ExitOnError)
|
||||
|
|
@ -47,32 +51,60 @@ func main() {
|
|||
return
|
||||
}
|
||||
|
||||
slog.SetLogLoggerLevel(getLogLevel())
|
||||
slog.SetLogLoggerLevel(internal.GetLogLevel())
|
||||
|
||||
taskName := os.Args[1]
|
||||
|
||||
slog.Debug("Running task:", slog.String("task", taskName))
|
||||
slog.Debug("working dir:", slog.String("dir", process.GetWorkingDir()))
|
||||
|
||||
if taskName == "format" {
|
||||
needsSignals = false
|
||||
}
|
||||
|
||||
done := make(chan bool, 1)
|
||||
if needsSignals {
|
||||
done = RegisterSignals()
|
||||
}
|
||||
|
||||
if taskName == "watch" {
|
||||
fmt.Printf("Running in watch mode\n")
|
||||
os.Setenv("ENV", "development")
|
||||
os.Setenv("WATCH_MODE", "true")
|
||||
fmt.Printf("Starting processes...")
|
||||
fmt.Printf("Starting processes...\n")
|
||||
|
||||
copyassets.CopyAssets()
|
||||
astgen.GenAst(process.ExitOnError)
|
||||
|
||||
fmt.Printf("Generating CSS...\n")
|
||||
css.GenerateCss(process.ExitOnError)
|
||||
|
||||
// generate ast needs to be run after css generation
|
||||
astgen.GenAst(process.ExitOnError)
|
||||
run.EntGenerate()
|
||||
go func() {
|
||||
css.GenerateCssWatch(process.ExitOnError)
|
||||
}()
|
||||
|
||||
fmt.Printf("Starting server...\n")
|
||||
process.KillAll()
|
||||
go func() {
|
||||
_ = run.Server()
|
||||
}()
|
||||
startWatcher(reloader.OnFileChange)
|
||||
} else {
|
||||
if taskName == "schema" {
|
||||
if taskName == "version" {
|
||||
fmt.Printf("htmgo cli version %s\n", version)
|
||||
os.Exit(0)
|
||||
}
|
||||
if taskName == "format" {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println(fmt.Sprintf("Usage: htmgo format <file>"))
|
||||
os.Exit(1)
|
||||
}
|
||||
file := os.Args[2]
|
||||
if file == "." {
|
||||
formatter.FormatDir(process.GetWorkingDir())
|
||||
} else {
|
||||
formatter.FormatFile(os.Args[2])
|
||||
}
|
||||
} else if taskName == "schema" {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
fmt.Print("Enter entity name:")
|
||||
text, _ := reader.ReadString('\n')
|
||||
|
|
@ -86,19 +118,24 @@ func main() {
|
|||
} else if taskName == "css" {
|
||||
_ = css.GenerateCss(process.ExitOnError)
|
||||
} else if taskName == "ast" {
|
||||
css.GenerateCss(process.ExitOnError)
|
||||
_ = astgen.GenAst(process.ExitOnError)
|
||||
} else if taskName == "run" {
|
||||
_ = astgen.GenAst(process.ExitOnError)
|
||||
_ = css.GenerateCss(process.ExitOnError)
|
||||
run.MakeBuildable()
|
||||
_ = run.Server(process.ExitOnError)
|
||||
} else if taskName == "template" {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
fmt.Print("What would you like to call your new app?: ")
|
||||
text, _ := reader.ReadString('\n')
|
||||
text = strings.TrimSuffix(text, "\n")
|
||||
text = strings.ReplaceAll(text, " ", "-")
|
||||
text = strings.ToLower(text)
|
||||
downloadtemplate.DownloadTemplate(fmt.Sprintf("./%s", text))
|
||||
name := ""
|
||||
if len(os.Args) > 2 {
|
||||
name = os.Args[2]
|
||||
} else {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
fmt.Print("What would you like to call your new app?: ")
|
||||
name, _ = reader.ReadString('\n')
|
||||
}
|
||||
name = strings.TrimSuffix(name, "\n")
|
||||
name = strings.ReplaceAll(name, " ", "-")
|
||||
name = strings.ToLower(name)
|
||||
downloadtemplate.DownloadTemplate(fmt.Sprintf("./%s", name))
|
||||
} else if taskName == "build" {
|
||||
run.Build()
|
||||
} else if taskName == "generate" {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ func RegisterSignals() chan bool {
|
|||
fmt.Println("Received signal:", sig)
|
||||
// Perform cleanup
|
||||
fmt.Println("Cleaning up...")
|
||||
process.KillAll()
|
||||
|
||||
process.OnShutdown()
|
||||
// Signal that cleanup is done
|
||||
done <- true
|
||||
}()
|
||||
|
|
|
|||
|
|
@ -2,15 +2,21 @@ package astgen
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"golang.org/x/mod/modfile"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/maddalax/htmgo/cli/htmgo/internal/dirutil"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
"golang.org/x/mod/modfile"
|
||||
)
|
||||
|
||||
type Page struct {
|
||||
|
|
@ -24,15 +30,47 @@ type Partial struct {
|
|||
FuncName string
|
||||
Package string
|
||||
Import string
|
||||
Path string
|
||||
}
|
||||
|
||||
const GeneratedDirName = "__htmgo"
|
||||
const EchoModuleName = "github.com/labstack/echo/v4"
|
||||
const HttpModuleName = "net/http"
|
||||
const ChiModuleName = "github.com/go-chi/chi/v5"
|
||||
const ModuleName = "github.com/maddalax/htmgo/framework/h"
|
||||
|
||||
var PackageName = fmt.Sprintf("package %s", GeneratedDirName)
|
||||
var GeneratedFileLine = fmt.Sprintf("// Package %s THIS FILE IS GENERATED. DO NOT EDIT.", GeneratedDirName)
|
||||
|
||||
func toPascaleCase(input string) string {
|
||||
words := strings.Split(input, "_")
|
||||
for i := range words {
|
||||
words[i] = strings.Title(strings.ToLower(words[i]))
|
||||
}
|
||||
return strings.Join(words, "")
|
||||
}
|
||||
|
||||
func isValidGoVariableName(name string) bool {
|
||||
// Variable name must not be empty
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
// First character must be a letter or underscore
|
||||
if !unicode.IsLetter(rune(name[0])) && name[0] != '_' {
|
||||
return false
|
||||
}
|
||||
// Remaining characters must be letters, digits, or underscores
|
||||
for _, char := range name[1:] {
|
||||
if !unicode.IsLetter(char) && !unicode.IsDigit(char) && char != '_' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizePath(path string) string {
|
||||
return strings.ReplaceAll(path, `\`, "/")
|
||||
}
|
||||
|
||||
func sliceCommonPrefix(dir1, dir2 string) string {
|
||||
// Use filepath.Clean to normalize the paths
|
||||
dir1 = filepath.Clean(dir1)
|
||||
|
|
@ -58,9 +96,35 @@ func sliceCommonPrefix(dir1, dir2 string) string {
|
|||
|
||||
// Return the longer one
|
||||
if len(slicedDir1) > len(slicedDir2) {
|
||||
return slicedDir1
|
||||
return normalizePath(slicedDir1)
|
||||
}
|
||||
return slicedDir2
|
||||
return normalizePath(slicedDir2)
|
||||
}
|
||||
|
||||
func hasOnlyReqContextParam(funcType *ast.FuncType) bool {
|
||||
if len(funcType.Params.List) != 1 {
|
||||
return false
|
||||
}
|
||||
if funcType.Params.List[0].Names == nil {
|
||||
return false
|
||||
}
|
||||
if len(funcType.Params.List[0].Names) != 1 {
|
||||
return false
|
||||
}
|
||||
t := funcType.Params.List[0].Type
|
||||
name, ok := t.(*ast.StarExpr)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
selectorExpr, ok := name.X.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
ident, ok := selectorExpr.X.(*ast.Ident)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return ident.Name == "h" && selectorExpr.Sel.Name == "RequestContext"
|
||||
}
|
||||
|
||||
func findPublicFuncsReturningHPartial(dir string, predicate func(partial Partial) bool) ([]Partial, error) {
|
||||
|
|
@ -99,10 +163,11 @@ func findPublicFuncsReturningHPartial(dir string, predicate func(partial Partial
|
|||
if selectorExpr, ok := starExpr.X.(*ast.SelectorExpr); ok {
|
||||
// Check if the package name is 'h' and type is 'Partial'.
|
||||
if ident, ok := selectorExpr.X.(*ast.Ident); ok && ident.Name == "h" {
|
||||
if selectorExpr.Sel.Name == "Partial" {
|
||||
if selectorExpr.Sel.Name == "Partial" && hasOnlyReqContextParam(funcDecl.Type) {
|
||||
p := Partial{
|
||||
Package: node.Name.Name,
|
||||
Import: sliceCommonPrefix(cwd, strings.ReplaceAll(filepath.Dir(path), `\`, `/`)),
|
||||
Path: normalizePath(sliceCommonPrefix(cwd, path)),
|
||||
Import: sliceCommonPrefix(cwd, normalizePath(filepath.Dir(path))),
|
||||
FuncName: funcDecl.Name.Name,
|
||||
}
|
||||
if predicate(p) {
|
||||
|
|
@ -165,11 +230,11 @@ func findPublicFuncsReturningHPage(dir string) ([]Page, error) {
|
|||
if selectorExpr, ok := starExpr.X.(*ast.SelectorExpr); ok {
|
||||
// Check if the package name is 'h' and type is 'Partial'.
|
||||
if ident, ok := selectorExpr.X.(*ast.Ident); ok && ident.Name == "h" {
|
||||
if selectorExpr.Sel.Name == "Page" {
|
||||
if selectorExpr.Sel.Name == "Page" && hasOnlyReqContextParam(funcDecl.Type) {
|
||||
pages = append(pages, Page{
|
||||
Package: node.Name.Name,
|
||||
Import: strings.ReplaceAll(filepath.Dir(path), `\`, `/`),
|
||||
Path: path,
|
||||
Import: normalizePath(filepath.Dir(path)),
|
||||
Path: normalizePath(path),
|
||||
FuncName: funcDecl.Name.Name,
|
||||
})
|
||||
break
|
||||
|
|
@ -195,69 +260,51 @@ func findPublicFuncsReturningHPage(dir string) ([]Page, error) {
|
|||
}
|
||||
|
||||
func buildGetPartialFromContext(builder *CodeBuilder, partials []Partial) {
|
||||
fName := "GetPartialFromContext"
|
||||
moduleName := GetModuleName()
|
||||
|
||||
body := `
|
||||
path := ctx.Request().URL.Path
|
||||
`
|
||||
|
||||
if len(partials) == 0 {
|
||||
body = ""
|
||||
var routerHandlerMethod = func(path string, caller string) string {
|
||||
return fmt.Sprintf(`
|
||||
router.Handle("%s", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cc := r.Context().Value(h.RequestContextKey).(*h.RequestContext)
|
||||
partial := %s(cc)
|
||||
if partial == nil {
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
h.PartialView(w, partial)
|
||||
}))`, path, caller)
|
||||
}
|
||||
|
||||
moduleName := GetModuleName()
|
||||
handlerMethods := make([]string, 0)
|
||||
|
||||
for _, f := range partials {
|
||||
if f.FuncName == fName {
|
||||
continue
|
||||
}
|
||||
caller := fmt.Sprintf("%s.%s", f.Package, f.FuncName)
|
||||
path := fmt.Sprintf("/%s/%s.%s", moduleName, f.Import, f.FuncName)
|
||||
|
||||
body += fmt.Sprintf(`
|
||||
if path == "%s" || path == "%s" {
|
||||
cc := ctx.(*h.RequestContext)
|
||||
return %s(cc)
|
||||
}
|
||||
`, f.FuncName, path, caller)
|
||||
handlerMethods = append(handlerMethods, routerHandlerMethod(path, caller))
|
||||
}
|
||||
|
||||
body += "return nil"
|
||||
|
||||
f := Function{
|
||||
Name: fName,
|
||||
Parameters: []NameType{
|
||||
{Name: "ctx", Type: "echo.Context"},
|
||||
},
|
||||
Return: []ReturnType{
|
||||
{Type: "*h.Partial"},
|
||||
},
|
||||
Body: body,
|
||||
}
|
||||
|
||||
builder.Append(builder.BuildFunction(f))
|
||||
|
||||
registerFunction := fmt.Sprintf(`
|
||||
func RegisterPartials(f *echo.Echo) {
|
||||
f.Any("%s/partials*", func(ctx echo.Context) error {
|
||||
partial := GetPartialFromContext(ctx)
|
||||
if partial == nil {
|
||||
return ctx.NoContent(404)
|
||||
}
|
||||
return h.PartialView(ctx, partial)
|
||||
})
|
||||
func RegisterPartials(router *chi.Mux) {
|
||||
%s
|
||||
}
|
||||
`, moduleName)
|
||||
`, strings.Join(handlerMethods, "\n"))
|
||||
|
||||
builder.AppendLine(registerFunction)
|
||||
}
|
||||
|
||||
func writePartialsFile() {
|
||||
config := dirutil.GetConfig()
|
||||
|
||||
cwd := process.GetWorkingDir()
|
||||
partialPath := filepath.Join(cwd, "partials")
|
||||
partialPath := filepath.Join(cwd)
|
||||
partials, err := findPublicFuncsReturningHPartial(partialPath, func(partial Partial) bool {
|
||||
return partial.FuncName != "GetPartialFromContext"
|
||||
})
|
||||
|
||||
partials = h.Filter(partials, func(partial Partial) bool {
|
||||
return !dirutil.IsGlobExclude(partial.Path, config.AutomaticPartialRoutingIgnore)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
|
|
@ -266,8 +313,12 @@ func writePartialsFile() {
|
|||
builder := NewCodeBuilder(nil)
|
||||
builder.AppendLine(GeneratedFileLine)
|
||||
builder.AppendLine(PackageName)
|
||||
builder.AddImport(ModuleName)
|
||||
builder.AddImport(EchoModuleName)
|
||||
builder.AddImport(ChiModuleName)
|
||||
|
||||
if len(partials) > 0 {
|
||||
builder.AddImport(ModuleName)
|
||||
builder.AddImport(HttpModuleName)
|
||||
}
|
||||
|
||||
moduleName := GetModuleName()
|
||||
for _, partial := range partials {
|
||||
|
|
@ -290,6 +341,17 @@ func formatRoute(path string) string {
|
|||
path = strings.ReplaceAll(path, "_", "/")
|
||||
path = strings.ReplaceAll(path, ".", "/")
|
||||
path = strings.ReplaceAll(path, "\\", "/")
|
||||
|
||||
parts := strings.Split(path, "/")
|
||||
|
||||
for i, part := range parts {
|
||||
if strings.HasPrefix(part, ":") {
|
||||
parts[i] = fmt.Sprintf("{%s}", part[1:])
|
||||
}
|
||||
}
|
||||
|
||||
path = strings.Join(parts, "/")
|
||||
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
|
|
@ -303,14 +365,20 @@ func formatRoute(path string) string {
|
|||
}
|
||||
|
||||
func writePagesFile() {
|
||||
config := dirutil.GetConfig()
|
||||
|
||||
builder := NewCodeBuilder(nil)
|
||||
builder.AppendLine(GeneratedFileLine)
|
||||
builder.AppendLine(PackageName)
|
||||
builder.AddImport(EchoModuleName)
|
||||
builder.AddImport(HttpModuleName)
|
||||
builder.AddImport(ChiModuleName)
|
||||
|
||||
pages, _ := findPublicFuncsReturningHPage("pages")
|
||||
|
||||
pages = h.Filter(pages, func(page Page) bool {
|
||||
return !dirutil.IsGlobExclude(page.Path, config.AutomaticPageRoutingIgnore)
|
||||
})
|
||||
|
||||
if len(pages) > 0 {
|
||||
builder.AddImport(ModuleName)
|
||||
}
|
||||
|
|
@ -331,18 +399,20 @@ func writePagesFile() {
|
|||
for _, page := range pages {
|
||||
call := fmt.Sprintf("%s.%s", page.Package, page.FuncName)
|
||||
|
||||
body += fmt.Sprintf(`
|
||||
f.GET("%s", func(ctx echo.Context) error {
|
||||
cc := ctx.(*h.RequestContext)
|
||||
return h.HtmlView(ctx, %s(cc))
|
||||
body += fmt.Sprintf(
|
||||
`
|
||||
router.Get("%s", func(writer http.ResponseWriter, request *http.Request) {
|
||||
cc := request.Context().Value(h.RequestContextKey).(*h.RequestContext)
|
||||
h.HtmlView(writer, %s(cc))
|
||||
})
|
||||
`, formatRoute(page.Path), call)
|
||||
`, formatRoute(page.Path), call,
|
||||
)
|
||||
}
|
||||
|
||||
f := Function{
|
||||
Name: fName,
|
||||
Parameters: []NameType{
|
||||
{Name: "f", Type: "*echo.Echo"},
|
||||
{Name: "router", Type: "*chi.Mux"},
|
||||
},
|
||||
Body: body,
|
||||
}
|
||||
|
|
@ -354,9 +424,96 @@ func writePagesFile() {
|
|||
})
|
||||
}
|
||||
|
||||
func writeAssetsFile() {
|
||||
cwd := process.GetWorkingDir()
|
||||
config := dirutil.GetConfig()
|
||||
|
||||
slog.Debug("writing assets file", slog.String("cwd", cwd), slog.String("config", config.PublicAssetPath))
|
||||
|
||||
distAssets := filepath.Join(cwd, "assets", "dist")
|
||||
hasAssets := false
|
||||
|
||||
builder := strings.Builder{}
|
||||
|
||||
builder.WriteString(`package assets`)
|
||||
builder.WriteString("\n")
|
||||
|
||||
filepath.WalkDir(distAssets, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(d.Name(), ".") {
|
||||
return nil
|
||||
}
|
||||
|
||||
path = strings.ReplaceAll(path, distAssets, "")
|
||||
httpUrl := normalizePath(fmt.Sprintf("%s%s", config.PublicAssetPath, path))
|
||||
|
||||
path = normalizePath(path)
|
||||
path = strings.ReplaceAll(path, "/", "_")
|
||||
path = strings.ReplaceAll(path, "//", "_")
|
||||
|
||||
name := strings.ReplaceAll(path, ".", "_")
|
||||
name = strings.ReplaceAll(name, "-", "_")
|
||||
|
||||
name = toPascaleCase(name)
|
||||
|
||||
if isValidGoVariableName(name) {
|
||||
builder.WriteString(fmt.Sprintf(`const %s = "%s"`, name, httpUrl))
|
||||
builder.WriteString("\n")
|
||||
hasAssets = true
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
builder.WriteString("\n")
|
||||
|
||||
str := builder.String()
|
||||
|
||||
if hasAssets {
|
||||
WriteFile(filepath.Join(GeneratedDirName, "assets", "assets-generated.go"), func(content *ast.File) string {
|
||||
return str
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func HasModuleFile(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return !os.IsNotExist(err)
|
||||
}
|
||||
|
||||
func CheckPagesDirectory(path string) error {
|
||||
pagesPath := filepath.Join(path, "pages")
|
||||
_, err := os.Stat(pagesPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("The directory pages does not exist.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetModuleName() string {
|
||||
wd := process.GetWorkingDir()
|
||||
modPath := filepath.Join(wd, "go.mod")
|
||||
|
||||
if HasModuleFile(modPath) == false {
|
||||
fmt.Fprintf(os.Stderr, "Module not found: go.mod file does not exist.")
|
||||
return ""
|
||||
}
|
||||
|
||||
checkDir := CheckPagesDirectory(wd)
|
||||
if checkDir != nil {
|
||||
fmt.Fprintf(os.Stderr, checkDir.Error())
|
||||
return ""
|
||||
}
|
||||
|
||||
goModBytes, err := os.ReadFile(modPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error reading go.mod: %v\n", err)
|
||||
|
|
@ -375,21 +532,23 @@ func GenAst(flags ...process.RunFlag) error {
|
|||
}
|
||||
writePartialsFile()
|
||||
writePagesFile()
|
||||
writeAssetsFile()
|
||||
|
||||
WriteFile("__htmgo/setup-generated.go", func(content *ast.File) string {
|
||||
return `
|
||||
|
||||
return fmt.Sprintf(`
|
||||
// Package __htmgo THIS FILE IS GENERATED. DO NOT EDIT.
|
||||
package __htmgo
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"%s"
|
||||
)
|
||||
|
||||
func Register(e *echo.Echo) {
|
||||
RegisterPartials(e)
|
||||
RegisterPages(e)
|
||||
func Register(r *chi.Mux) {
|
||||
RegisterPartials(r)
|
||||
RegisterPages(r)
|
||||
}
|
||||
`
|
||||
`, ChiModuleName)
|
||||
})
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -1,82 +0,0 @@
|
|||
package astgen
|
||||
|
||||
// OrderedMap is a generic data structure that maintains the order of keys.
|
||||
type OrderedMap[K comparable, V any] struct {
|
||||
keys []K
|
||||
values map[K]V
|
||||
}
|
||||
|
||||
// Entries returns the key-value pairs in the order they were added.
|
||||
func (om *OrderedMap[K, V]) Entries() []struct {
|
||||
Key K
|
||||
Value V
|
||||
} {
|
||||
entries := make([]struct {
|
||||
Key K
|
||||
Value V
|
||||
}, len(om.keys))
|
||||
for i, key := range om.keys {
|
||||
entries[i] = struct {
|
||||
Key K
|
||||
Value V
|
||||
}{
|
||||
Key: key,
|
||||
Value: om.values[key],
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// NewOrderedMap creates a new OrderedMap.
|
||||
func NewOrderedMap[K comparable, V any]() *OrderedMap[K, V] {
|
||||
return &OrderedMap[K, V]{
|
||||
keys: []K{},
|
||||
values: make(map[K]V),
|
||||
}
|
||||
}
|
||||
|
||||
// Set adds or updates a key-value pair in the OrderedMap.
|
||||
func (om *OrderedMap[K, V]) Set(key K, value V) {
|
||||
// Check if the key already exists
|
||||
if _, exists := om.values[key]; !exists {
|
||||
om.keys = append(om.keys, key) // Append key to the keys slice if it's a new key
|
||||
}
|
||||
om.values[key] = value
|
||||
}
|
||||
|
||||
// Get retrieves a value by key.
|
||||
func (om *OrderedMap[K, V]) Get(key K) (V, bool) {
|
||||
value, exists := om.values[key]
|
||||
return value, exists
|
||||
}
|
||||
|
||||
// Keys returns the keys in the order they were added.
|
||||
func (om *OrderedMap[K, V]) Keys() []K {
|
||||
return om.keys
|
||||
}
|
||||
|
||||
// Values returns the values in the order of their keys.
|
||||
func (om *OrderedMap[K, V]) Values() []V {
|
||||
values := make([]V, len(om.keys))
|
||||
for i, key := range om.keys {
|
||||
values[i] = om.values[key]
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
// Delete removes a key-value pair from the OrderedMap.
|
||||
func (om *OrderedMap[K, V]) Delete(key K) {
|
||||
if _, exists := om.values[key]; exists {
|
||||
// Remove the key from the map
|
||||
delete(om.values, key)
|
||||
|
||||
// Remove the key from the keys slice
|
||||
for i, k := range om.keys {
|
||||
if k == key {
|
||||
om.keys = append(om.keys[:i], om.keys[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
cli/htmgo/tasks/astgen/project-sample/.gitignore
vendored
Normal file
6
cli/htmgo/tasks/astgen/project-sample/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/assets/dist
|
||||
tmp
|
||||
node_modules
|
||||
.idea
|
||||
__htmgo
|
||||
dist
|
||||
13
cli/htmgo/tasks/astgen/project-sample/assets.go
Normal file
13
cli/htmgo/tasks/astgen/project-sample/assets.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
//go:build !prod
|
||||
// +build !prod
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"astgen-project-sample/internal/embedded"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
func GetStaticAssets() fs.FS {
|
||||
return embedded.NewOsFs()
|
||||
}
|
||||
16
cli/htmgo/tasks/astgen/project-sample/assets_prod.go
Normal file
16
cli/htmgo/tasks/astgen/project-sample/assets_prod.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
//go:build prod
|
||||
// +build prod
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed assets/dist/*
|
||||
var staticAssets embed.FS
|
||||
|
||||
func GetStaticAssets() fs.FS {
|
||||
return staticAssets
|
||||
}
|
||||
11
cli/htmgo/tasks/astgen/project-sample/go.mod
Normal file
11
cli/htmgo/tasks/astgen/project-sample/go.mod
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
module astgen-project-sample
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
18
cli/htmgo/tasks/astgen/project-sample/go.sum
Normal file
18
cli/htmgo/tasks/astgen/project-sample/go.sum
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4=
|
||||
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
||||
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
21
cli/htmgo/tasks/astgen/project-sample/htmgo.yml
Normal file
21
cli/htmgo/tasks/astgen/project-sample/htmgo.yml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# htmgo configuration
|
||||
|
||||
# if tailwindcss is enabled, htmgo will automatically compile your tailwind and output it to assets/dist
|
||||
tailwind: true
|
||||
|
||||
# which directories to ignore when watching for changes, supports glob patterns through https://github.com/bmatcuk/doublestar
|
||||
watch_ignore: [".git", "node_modules", "dist/*"]
|
||||
|
||||
# files to watch for changes, supports glob patterns through https://github.com/bmatcuk/doublestar
|
||||
watch_files: ["**/*.go", "**/*.css", "**/*.md"]
|
||||
|
||||
# files or directories to ignore when automatically registering routes for pages
|
||||
# supports glob patterns through https://github.com/bmatcuk/doublestar
|
||||
automatic_page_routing_ignore: ["root.go"]
|
||||
|
||||
# files or directories to ignore when automatically registering routes for partials
|
||||
# supports glob patterns through https://github.com/bmatcuk/doublestar
|
||||
automatic_partial_routing_ignore: []
|
||||
|
||||
# url path of where the public assets are located
|
||||
public_asset_path: "/public"
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package embedded
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
type OsFs struct {
|
||||
}
|
||||
|
||||
func (receiver OsFs) Open(name string) (fs.File, error) {
|
||||
return os.Open(name)
|
||||
}
|
||||
|
||||
func NewOsFs() OsFs {
|
||||
return OsFs{}
|
||||
}
|
||||
36
cli/htmgo/tasks/astgen/project-sample/main.go
Normal file
36
cli/htmgo/tasks/astgen/project-sample/main.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"astgen-project-sample/__htmgo"
|
||||
"fmt"
|
||||
"github.com/maddalax/htmgo/framework/config"
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
"github.com/maddalax/htmgo/framework/service"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
locator := service.NewLocator()
|
||||
cfg := config.Get()
|
||||
|
||||
h.Start(h.AppOpts{
|
||||
ServiceLocator: locator,
|
||||
LiveReload: true,
|
||||
Register: func(app *h.App) {
|
||||
sub, err := fs.Sub(GetStaticAssets(), "assets/dist")
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
http.FileServerFS(sub)
|
||||
|
||||
// change this in htmgo.yml (public_asset_path)
|
||||
app.Router.Handle(fmt.Sprintf("%s/*", cfg.PublicAssetPath),
|
||||
http.StripPrefix(cfg.PublicAssetPath, http.FileServerFS(sub)))
|
||||
|
||||
__htmgo.Register(app.Router)
|
||||
},
|
||||
})
|
||||
}
|
||||
30
cli/htmgo/tasks/astgen/project-sample/pages/index.go
Normal file
30
cli/htmgo/tasks/astgen/project-sample/pages/index.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
)
|
||||
|
||||
func IndexPage(ctx *h.RequestContext) *h.Page {
|
||||
return RootPage(
|
||||
h.Div(
|
||||
h.Class("flex flex-col gap-4 items-center pt-24 min-h-screen bg-neutral-100"),
|
||||
h.H3(
|
||||
h.Id("intro-text"),
|
||||
h.Text("hello htmgo"),
|
||||
h.Class("text-5xl"),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("mt-3"),
|
||||
),
|
||||
h.Div(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func TestPartial(ctx *h.RequestContext) *h.Partial {
|
||||
return h.NewPartial(
|
||||
h.Div(
|
||||
h.Text("Hello World"),
|
||||
),
|
||||
)
|
||||
}
|
||||
40
cli/htmgo/tasks/astgen/project-sample/pages/root.go
Normal file
40
cli/htmgo/tasks/astgen/project-sample/pages/root.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
)
|
||||
|
||||
func RootPage(children ...h.Ren) *h.Page {
|
||||
title := "htmgo template"
|
||||
description := "an example of the htmgo template"
|
||||
author := "htmgo"
|
||||
url := "https://htmgo.dev"
|
||||
|
||||
return h.NewPage(
|
||||
h.Html(
|
||||
h.HxExtensions(
|
||||
h.BaseExtensions(),
|
||||
),
|
||||
h.Head(
|
||||
h.Title(
|
||||
h.Text(title),
|
||||
),
|
||||
h.Meta("viewport", "width=device-width, initial-scale=1"),
|
||||
h.Meta("title", title),
|
||||
h.Meta("charset", "utf-8"),
|
||||
h.Meta("author", author),
|
||||
h.Meta("description", description),
|
||||
h.Meta("og:title", title),
|
||||
h.Meta("og:url", url),
|
||||
h.Link("canonical", url),
|
||||
h.Meta("og:description", description),
|
||||
),
|
||||
h.Body(
|
||||
h.Div(
|
||||
h.Class("flex flex-col gap-2 bg-white h-full"),
|
||||
h.Fragment(children...),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
18
cli/htmgo/tasks/astgen/project-sample/partials/index.go
Normal file
18
cli/htmgo/tasks/astgen/project-sample/partials/index.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package partials
|
||||
|
||||
import "github.com/maddalax/htmgo/framework/h"
|
||||
|
||||
func CountersPartial(ctx *h.RequestContext) *h.Partial {
|
||||
return h.NewPartial(
|
||||
h.Div(
|
||||
h.Text("my counter"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func SwapFormError(ctx *h.RequestContext, error string) *h.Partial {
|
||||
return h.SwapPartial(
|
||||
ctx,
|
||||
h.Div(),
|
||||
)
|
||||
}
|
||||
66
cli/htmgo/tasks/astgen/registration_test.go
Normal file
66
cli/htmgo/tasks/astgen/registration_test.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package astgen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/internal/dirutil"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAstGen(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
workingDir, err := filepath.Abs("./project-sample")
|
||||
|
||||
assert.NoError(t, err)
|
||||
process.SetWorkingDir(workingDir)
|
||||
assert.NoError(t, os.Chdir(workingDir))
|
||||
|
||||
err = dirutil.DeleteDir(filepath.Join(process.GetWorkingDir(), "__htmgo"))
|
||||
assert.NoError(t, err)
|
||||
err = process.Run(process.NewRawCommand("", "go build ."))
|
||||
assert.Error(t, err)
|
||||
err = GenAst()
|
||||
assert.NoError(t, err)
|
||||
|
||||
go func() {
|
||||
// project was buildable after astgen, confirmed working
|
||||
err = process.Run(process.NewRawCommand("server", "go run ."))
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
time.Sleep(time.Second * 1)
|
||||
|
||||
urls := []string{
|
||||
"/astgen-project-sample/partials.CountersPartial",
|
||||
"/",
|
||||
"/astgen-project-sample/pages.TestPartial",
|
||||
}
|
||||
|
||||
defer func() {
|
||||
serverProcess := process.GetProcessByName("server")
|
||||
assert.NotNil(t, serverProcess)
|
||||
process.KillProcess(*serverProcess)
|
||||
}()
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
for _, url := range urls {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// ensure we can get a 200 response on the partials
|
||||
resp, e := http.Get(fmt.Sprintf("http://localhost:3000%s", url))
|
||||
assert.NoError(t, e)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode, fmt.Sprintf("%s was not a 200 response", url))
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
|
@ -7,16 +7,3 @@ import (
|
|||
func PanicF(format string, args ...interface{}) {
|
||||
panic(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func Unique[T any](slice []T, key func(item T) string) []T {
|
||||
var result []T
|
||||
seen := make(map[string]bool)
|
||||
for _, v := range slice {
|
||||
k := key(v)
|
||||
if _, ok := seen[k]; !ok {
|
||||
seen[k] = true
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,8 +62,12 @@ func WriteFile(path string, cb func(content *ast.File) string) {
|
|||
}
|
||||
}
|
||||
|
||||
// Define the file path where you want to save the buffer
|
||||
process.Run("git add "+path, process.Silent)
|
||||
cmd := "git add " + path
|
||||
process.Run(process.NewRawCommand(
|
||||
cmd,
|
||||
cmd,
|
||||
process.Silent,
|
||||
))
|
||||
|
||||
// Save the buffer to a file
|
||||
err = os.WriteFile(path, bytes, 0644)
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ func getModuleVersion(modulePath string) (string, error) {
|
|||
}
|
||||
|
||||
func CopyAssets() {
|
||||
dirutil.CreateHtmgoDir()
|
||||
|
||||
moduleName := "github.com/maddalax/htmgo/framework"
|
||||
modulePath := module.GetDependencyPath(moduleName)
|
||||
|
||||
|
|
@ -76,14 +78,21 @@ func CopyAssets() {
|
|||
if strings.HasSuffix(path, "tailwind.config.js") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
return !exists
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
|
||||
if !dirutil.HasFileFromRoot("tailwind.config.js") {
|
||||
if dirutil.HasFileFromRoot("assets/public") {
|
||||
err = dirutil.CopyDir(filepath.Join(process.GetWorkingDir(), "assets/public"), filepath.Join(process.GetWorkingDir(), "assets/dist"),
|
||||
func(path string, exists bool) bool {
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
if dirutil.GetConfig().Tailwind && !dirutil.HasFileFromRoot("tailwind.config.js") {
|
||||
err = dirutil.CopyFile(
|
||||
filepath.Join(assetCssDir, "tailwind.config.js"),
|
||||
filepath.Join(process.GetWorkingDir(), "tailwind.config.js"),
|
||||
|
|
@ -94,5 +103,6 @@ func CopyAssets() {
|
|||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
|
||||
process.Run(fmt.Sprintf("cd %s && git add .", destDirCss))
|
||||
cmd := fmt.Sprintf("cd %s && git add .", destDirCss)
|
||||
process.Run(process.NewRawCommand(cmd, cmd, process.Silent))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
)
|
||||
|
||||
func IsTailwindEnabled() bool {
|
||||
return dirutil.HasFileFromRoot("tailwind.config.js")
|
||||
return dirutil.GetConfig().Tailwind && dirutil.HasFileFromRoot("tailwind.config.js")
|
||||
}
|
||||
|
||||
func Setup() bool {
|
||||
|
|
@ -22,7 +22,7 @@ func Setup() bool {
|
|||
}
|
||||
downloadTailwindCli()
|
||||
|
||||
if dirutil.HasFileFromRoot("assets/css/input.css") {
|
||||
if !dirutil.HasFileFromRoot("assets/css/input.css") {
|
||||
copyassets.CopyAssets()
|
||||
}
|
||||
|
||||
|
|
@ -42,19 +42,8 @@ func GenerateCss(flags ...process.RunFlag) error {
|
|||
return nil
|
||||
}
|
||||
exec := GetTailwindExecutableName()
|
||||
return process.RunMany([]string{
|
||||
fmt.Sprintf("%s -i ./assets/css/input.css -o ./assets/dist/main.css -c ./tailwind.config.js", exec),
|
||||
}, append(flags, process.Silent)...)
|
||||
}
|
||||
|
||||
func GenerateCssWatch(flags ...process.RunFlag) error {
|
||||
if !Setup() {
|
||||
return nil
|
||||
}
|
||||
exec := GetTailwindExecutableName()
|
||||
return process.RunMany([]string{
|
||||
fmt.Sprintf("%s -i ./assets/css/input.css -o ./assets/dist/main.css -c ./tailwind.config.js --watch=always", exec),
|
||||
}, append(flags, process.KillOnlyOnExit, process.Silent)...)
|
||||
cmd := fmt.Sprintf("%s -i ./assets/css/input.css -o ./assets/dist/main.css -c ./tailwind.config.js", exec)
|
||||
return process.Run(process.NewRawCommand("tailwind", cmd, append(flags, process.Silent)...))
|
||||
}
|
||||
|
||||
func downloadTailwindCli() {
|
||||
|
|
@ -89,8 +78,10 @@ func downloadTailwindCli() {
|
|||
log.Fatal(fmt.Sprintf("Unsupported OS/ARCH: %s/%s", os, arch))
|
||||
}
|
||||
fileName := fmt.Sprintf(`tailwindcss-%s`, distro)
|
||||
url := fmt.Sprintf(`https://github.com/tailwindlabs/tailwindcss/releases/latest/download/%s`, fileName)
|
||||
process.Run(fmt.Sprintf(`curl -LO %s`, url), process.ExitOnError)
|
||||
url := fmt.Sprintf(`https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.16/%s`, fileName)
|
||||
|
||||
cmd := fmt.Sprintf(`curl -LO %s`, url)
|
||||
process.Run(process.NewRawCommand("tailwind-cli-download", cmd, process.ExitOnError))
|
||||
|
||||
outputFileName := GetTailwindExecutableName()
|
||||
newPath := filepath.Join(process.GetWorkingDir(), outputFileName)
|
||||
|
|
@ -99,8 +90,14 @@ func downloadTailwindCli() {
|
|||
filepath.Join(process.GetWorkingDir(), fileName),
|
||||
newPath)
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Error moving file: %s\n", err.Error())
|
||||
}
|
||||
|
||||
if os != "windows" {
|
||||
err = process.Run(fmt.Sprintf(`chmod +x %s`, newPath), process.ExitOnError)
|
||||
err = process.Run(process.NewRawCommand("chmod-tailwind-cli",
|
||||
fmt.Sprintf(`chmod +x %s`, newPath),
|
||||
process.ExitOnError))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ func DownloadTemplate(outPath string) {
|
|||
|
||||
fmt.Printf("Downloading template %s\n to %s", templateName, tempOut)
|
||||
|
||||
err := process.Run("git clone https://github.com/maddalax/htmgo --depth=1 "+tempOut, process.ExitOnError)
|
||||
err := process.Run(process.NewRawCommand("clone-template", "git clone https://github.com/maddalax/htmgo --depth=1 "+tempOut, process.ExitOnError))
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Error cloning the template, error: %s\n", err.Error())
|
||||
|
|
@ -69,13 +69,11 @@ func DownloadTemplate(outPath string) {
|
|||
slog.Debug("current working dir", slog.String("cwd", process.GetWorkingDir()))
|
||||
|
||||
commands := [][]string{
|
||||
{"go", "get", "github.com/maddalax/htmgo/framework@latest"},
|
||||
{"go", "get", "github.com/maddalax/htmgo/framework-ui@latest"},
|
||||
{"git", "init"},
|
||||
}
|
||||
|
||||
for _, command := range commands {
|
||||
process.Run(strings.Join(command, " "), process.ExitOnError)
|
||||
process.Run(process.NewRawCommand("", strings.Join(command, " "), process.ExitOnError))
|
||||
}
|
||||
|
||||
_ = util.ReplaceTextInFile(filepath.Join(newDir, "go.mod"),
|
||||
|
|
|
|||
50
cli/htmgo/tasks/formatter/formatter.go
Normal file
50
cli/htmgo/tasks/formatter/formatter.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package formatter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/maddalax/htmgo/tools/html-to-htmgo/htmltogo"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func FormatDir(dir string) {
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
fmt.Printf("error reading dir: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
FormatDir(filepath.Join(dir, file.Name()))
|
||||
} else {
|
||||
FormatFile(filepath.Join(dir, file.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func FormatFile(file string) {
|
||||
if !strings.HasSuffix(file, ".go") {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("formatting file: %s\n", file)
|
||||
|
||||
source, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
fmt.Printf("error reading file: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
str := string(source)
|
||||
|
||||
if !strings.Contains(str, "github.com/maddalax/htmgo/framework/h") {
|
||||
return
|
||||
}
|
||||
|
||||
parsed := htmltogo.Indent(str)
|
||||
|
||||
os.WriteFile(file, []byte(parsed), 0644)
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -4,16 +4,23 @@ package process
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func KillProcess(process *os.Process) error {
|
||||
if process == nil {
|
||||
func KillProcess(process CmdWithFlags) error {
|
||||
if process.Cmd == nil || process.Cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
return syscall.Kill(-process.Pid, syscall.SIGKILL)
|
||||
slog.Debug("killing process",
|
||||
slog.String("name", process.Name),
|
||||
slog.Int("pid", process.Cmd.Process.Pid))
|
||||
_ = syscall.Kill(-process.Cmd.Process.Pid, syscall.SIGKILL)
|
||||
time.Sleep(time.Millisecond * 50)
|
||||
return nil
|
||||
}
|
||||
|
||||
func PrepareCommand(command *exec.Cmd) {
|
||||
|
|
|
|||
|
|
@ -2,18 +2,20 @@ package process
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
func KillProcess(process *os.Process) error {
|
||||
if process == nil {
|
||||
func KillProcess(process CmdWithFlags) error {
|
||||
if process.Cmd == nil || process.Cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
Run(fmt.Sprintf("taskkill /F /T /PID %s", strconv.Itoa(process.Pid)))
|
||||
err := exec.Command("taskkill", "/F", "/T", "/PID", strconv.Itoa(process.Cmd.Process.Pid)).Run()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
time.Sleep(time.Millisecond * 50)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,28 +2,49 @@ package process
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/internal"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CmdWithFlags struct {
|
||||
flags []RunFlag
|
||||
cmd *exec.Cmd
|
||||
Flags []RunFlag
|
||||
Name string
|
||||
Cmd *exec.Cmd
|
||||
}
|
||||
|
||||
type RawCommand struct {
|
||||
Name string
|
||||
Args string
|
||||
Flags []RunFlag
|
||||
}
|
||||
|
||||
func NewRawCommand(name string, args string, flags ...RunFlag) RawCommand {
|
||||
if name == "" {
|
||||
name = args
|
||||
}
|
||||
c := RawCommand{Name: name, Args: args, Flags: flags}
|
||||
if c.Flags == nil {
|
||||
c.Flags = make([]RunFlag, 0)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
var workingDir string
|
||||
var commands = make([]CmdWithFlags, 0)
|
||||
var commands = make(map[string]CmdWithFlags)
|
||||
|
||||
func AppendRunning(cmd *exec.Cmd, flags ...RunFlag) {
|
||||
func AppendRunning(cmd *exec.Cmd, raw RawCommand) {
|
||||
slog.Debug("running", slog.String("command", strings.Join(cmd.Args, " ")),
|
||||
slog.String("dir", cmd.Dir),
|
||||
slog.String("cwd", GetWorkingDir()))
|
||||
commands = append(commands, CmdWithFlags{flags: flags, cmd: cmd})
|
||||
|
||||
commands[raw.Name] = CmdWithFlags{Flags: raw.Flags, Name: raw.Name, Cmd: cmd}
|
||||
}
|
||||
|
||||
func GetWorkingDir() string {
|
||||
|
|
@ -51,19 +72,66 @@ func shouldSkipKilling(flags []RunFlag, skipFlag []RunFlag) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
func StartLogger() {
|
||||
if internal.GetLogLevel() != slog.LevelDebug {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(time.Second * 5)
|
||||
items := make([]map[string]string, 0)
|
||||
for _, cmd := range commands {
|
||||
data := make(map[string]string)
|
||||
data["command"] = fmt.Sprintf("%s %s", cmd.Cmd.Path, strings.Join(cmd.Cmd.Args, " "))
|
||||
if cmd.Cmd.Process != nil {
|
||||
data["pid"] = fmt.Sprintf("%d", cmd.Cmd.Process.Pid)
|
||||
}
|
||||
items = append(items, data)
|
||||
}
|
||||
|
||||
fmt.Printf("Running processes:\n")
|
||||
for i, item := range items {
|
||||
fmt.Printf("%d: %+v\n", i, item)
|
||||
}
|
||||
fmt.Printf("\n")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func GetProcessByName(name string) *CmdWithFlags {
|
||||
for _, cmd := range commands {
|
||||
if cmd.Name == name {
|
||||
return &cmd
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func OnShutdown() {
|
||||
// request for shutdown
|
||||
for _, cmd := range commands {
|
||||
if cmd.Cmd != nil && cmd.Cmd.Process != nil {
|
||||
cmd.Cmd.Process.Signal(os.Interrupt)
|
||||
}
|
||||
}
|
||||
// give it a second
|
||||
time.Sleep(time.Second * 1)
|
||||
// force kill
|
||||
KillAll()
|
||||
}
|
||||
|
||||
func KillAll(skipFlag ...RunFlag) {
|
||||
|
||||
tries := 0
|
||||
updatedCommands := make([]CmdWithFlags, len(commands))
|
||||
updatedCommands := make(map[string]CmdWithFlags)
|
||||
for {
|
||||
tries++
|
||||
allFinished := true
|
||||
for i, cmd := range commands {
|
||||
if cmd.cmd.Process == nil {
|
||||
for _, cmd := range commands {
|
||||
if cmd.Cmd.Process == nil {
|
||||
allFinished = false
|
||||
|
||||
if tries > 50 {
|
||||
args := strings.Join(cmd.cmd.Args, " ")
|
||||
args := strings.Join(cmd.Cmd.Args, " ")
|
||||
slog.Debug("process is not running after 50 tries, breaking.", slog.String("command", args))
|
||||
allFinished = true
|
||||
break
|
||||
|
|
@ -72,7 +140,7 @@ func KillAll(skipFlag ...RunFlag) {
|
|||
continue
|
||||
}
|
||||
} else {
|
||||
updatedCommands[i] = cmd
|
||||
updatedCommands[cmd.Name] = cmd
|
||||
}
|
||||
}
|
||||
if allFinished {
|
||||
|
|
@ -80,18 +148,18 @@ func KillAll(skipFlag ...RunFlag) {
|
|||
}
|
||||
}
|
||||
|
||||
commands = make([]CmdWithFlags, 0)
|
||||
commands = make(map[string]CmdWithFlags)
|
||||
for _, command := range updatedCommands {
|
||||
if command.cmd != nil && command.cmd.Process != nil {
|
||||
commands = append(commands, command)
|
||||
if command.Cmd != nil && command.Cmd.Process != nil {
|
||||
commands[command.Name] = command
|
||||
}
|
||||
}
|
||||
|
||||
for _, command := range commands {
|
||||
if shouldSkipKilling(command.flags, skipFlag) {
|
||||
if shouldSkipKilling(command.Flags, skipFlag) {
|
||||
continue
|
||||
}
|
||||
err := KillProcess(command.cmd.Process)
|
||||
err := KillProcess(command)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
|
@ -100,15 +168,15 @@ func KillAll(skipFlag ...RunFlag) {
|
|||
for {
|
||||
finished := true
|
||||
for _, c := range commands {
|
||||
if c.cmd.Process == nil {
|
||||
if c.Cmd.Process == nil {
|
||||
continue
|
||||
}
|
||||
if shouldSkipKilling(c.flags, skipFlag) {
|
||||
if shouldSkipKilling(c.Flags, skipFlag) {
|
||||
continue
|
||||
}
|
||||
exists := PidExists(int32(c.cmd.Process.Pid))
|
||||
exists := PidExists(int32(c.Cmd.Process.Pid))
|
||||
if exists {
|
||||
KillProcess(c.cmd.Process)
|
||||
KillProcess(c)
|
||||
finished = false
|
||||
}
|
||||
}
|
||||
|
|
@ -121,12 +189,13 @@ func KillAll(skipFlag ...RunFlag) {
|
|||
}
|
||||
}
|
||||
|
||||
commands = make([]CmdWithFlags, 0)
|
||||
commands = make(map[string]CmdWithFlags)
|
||||
slog.Debug("all processes killed\n")
|
||||
}
|
||||
|
||||
func RunOrExit(command string) {
|
||||
_ = Run(command, ExitOnError)
|
||||
func RunOrExit(command RawCommand) {
|
||||
command.Flags = append(command.Flags, ExitOnError)
|
||||
_ = Run(command)
|
||||
}
|
||||
|
||||
type RunFlag int
|
||||
|
|
@ -137,11 +206,11 @@ const (
|
|||
KillOnlyOnExit
|
||||
)
|
||||
|
||||
func RunMany(commands []string, flags ...RunFlag) error {
|
||||
func RunMany(commands ...RawCommand) error {
|
||||
for _, command := range commands {
|
||||
err := Run(command, flags...)
|
||||
err := Run(command)
|
||||
if err != nil {
|
||||
if slices.Contains(flags, ExitOnError) {
|
||||
if slices.Contains(command.Flags, ExitOnError) {
|
||||
os.Exit(1)
|
||||
}
|
||||
return err
|
||||
|
|
@ -150,13 +219,35 @@ func RunMany(commands []string, flags ...RunFlag) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func Run(command string, flags ...RunFlag) error {
|
||||
parts := strings.Fields(command)
|
||||
cmd := exec.Command(parts[0], parts[1:]...)
|
||||
var mutex = &sync.Mutex{}
|
||||
|
||||
func Run(command RawCommand) error {
|
||||
mutex.Lock()
|
||||
|
||||
parts := strings.Fields(command.Args)
|
||||
|
||||
args := make([]string, 0)
|
||||
if len(parts) > 1 {
|
||||
args = parts[1:]
|
||||
}
|
||||
|
||||
path := parts[0]
|
||||
|
||||
existing := GetProcessByName(command.Name)
|
||||
|
||||
if existing != nil {
|
||||
slog.Debug("process already running, killing it", slog.String("command", command.Name))
|
||||
KillProcess(*existing)
|
||||
time.Sleep(time.Millisecond * 50)
|
||||
} else {
|
||||
slog.Debug("no existing process found for %s, safe to run...", slog.String("command", command.Name))
|
||||
}
|
||||
|
||||
cmd := exec.Command(path, args...)
|
||||
|
||||
PrepareCommand(cmd)
|
||||
|
||||
if slices.Contains(flags, Silent) {
|
||||
if slices.Contains(command.Flags, Silent) {
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
} else {
|
||||
|
|
@ -168,16 +259,21 @@ func Run(command string, flags ...RunFlag) error {
|
|||
cmd.Dir = workingDir
|
||||
}
|
||||
|
||||
AppendRunning(cmd, flags...)
|
||||
AppendRunning(cmd, command)
|
||||
|
||||
mutex.Unlock()
|
||||
|
||||
err := cmd.Run()
|
||||
|
||||
slog.Debug("command finished",
|
||||
slog.String("command", command),
|
||||
slog.String("command", command.Name),
|
||||
slog.String("args", command.Args),
|
||||
slog.String("dir", cmd.Dir),
|
||||
slog.String("cwd", GetWorkingDir()),
|
||||
slog.String("error", fmt.Sprintf("%v", err)))
|
||||
|
||||
delete(commands, command.Name)
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -186,10 +282,10 @@ func Run(command string, flags ...RunFlag) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
if slices.Contains(flags, ExitOnError) {
|
||||
if slices.Contains(command.Flags, ExitOnError) {
|
||||
slog.Error("Error running command: ",
|
||||
slog.String("error", err.Error()),
|
||||
slog.String("command", command))
|
||||
slog.String("command", command.Name))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import (
|
|||
"fmt"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/astgen"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/copyassets"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/css"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/run"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/util"
|
||||
"log/slog"
|
||||
|
|
@ -60,53 +61,84 @@ type Tasks struct {
|
|||
AstGen bool
|
||||
Run bool
|
||||
Ent bool
|
||||
Css bool
|
||||
}
|
||||
|
||||
func OnFileChange(events []*fsnotify.Event) {
|
||||
func OnFileChange(version string, events []*fsnotify.Event) {
|
||||
now := time.Now()
|
||||
|
||||
tasks := Tasks{}
|
||||
hasTask := false
|
||||
|
||||
for _, event := range events {
|
||||
c := NewChange(event)
|
||||
|
||||
if c.HasAnySuffix(".go~", ".css~") {
|
||||
continue
|
||||
}
|
||||
|
||||
if c.IsGenerated() {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Debug("file changed", slog.String("file", c.Name()))
|
||||
|
||||
if c.IsGo() && c.HasAnyPrefix("pages/", "partials/") {
|
||||
tasks.AstGen = true
|
||||
hasTask = true
|
||||
}
|
||||
|
||||
if c.IsGo() {
|
||||
tasks.Run = true
|
||||
tasks.Css = true
|
||||
hasTask = true
|
||||
}
|
||||
|
||||
if c.HasAnySuffix(".md") {
|
||||
tasks.Run = true
|
||||
}
|
||||
|
||||
if c.HasAnySuffix("tailwind.config.js", ".css") {
|
||||
tasks.Run = true
|
||||
hasTask = true
|
||||
}
|
||||
|
||||
if c.HasAnyPrefix("ent/schema") {
|
||||
tasks.Ent = true
|
||||
hasTask = true
|
||||
}
|
||||
|
||||
slog.Info("file changed", slog.String("file", c.Name()))
|
||||
// framework assets changed
|
||||
if c.HasAnySuffix("assets/dist/htmgo.js") {
|
||||
copyassets.CopyAssets()
|
||||
//tasks.Run = true
|
||||
}
|
||||
|
||||
// something in public folder changed
|
||||
if c.HasAnyPrefix("assets/public/") {
|
||||
copyassets.CopyAssets()
|
||||
}
|
||||
|
||||
if hasTask {
|
||||
slog.Info("file changed", slog.String("version", version), slog.String("file", c.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
if !hasTask {
|
||||
return
|
||||
}
|
||||
|
||||
deps := make([]func() any, 0)
|
||||
|
||||
if tasks.AstGen {
|
||||
deps = append(deps, func() any {
|
||||
return util.Trace("generate ast", func() any {
|
||||
go func() {
|
||||
util.Trace("generate ast", func() any {
|
||||
astgen.GenAst()
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
if tasks.Css {
|
||||
deps = append(deps, func() any {
|
||||
return util.Trace("generate css", func() any {
|
||||
css.GenerateCss()
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -134,13 +166,6 @@ func OnFileChange(events []*fsnotify.Event) {
|
|||
|
||||
wg.Wait()
|
||||
|
||||
if tasks.Run {
|
||||
util.Trace("kill all processes", func() any {
|
||||
process.KillAll(process.KillOnlyOnExit)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if tasks.Run {
|
||||
go run.Server()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,15 +9,26 @@ import (
|
|||
"os"
|
||||
)
|
||||
|
||||
func Build() {
|
||||
func MakeBuildable() {
|
||||
copyassets.CopyAssets()
|
||||
astgen.GenAst(process.ExitOnError)
|
||||
css.GenerateCss(process.ExitOnError)
|
||||
astgen.GenAst(process.ExitOnError)
|
||||
}
|
||||
|
||||
process.RunOrExit("mkdir -p ./dist")
|
||||
func Build() {
|
||||
MakeBuildable()
|
||||
|
||||
_ = os.RemoveAll("./dist")
|
||||
|
||||
err := os.Mkdir("./dist", 0755)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Error creating dist directory", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if os.Getenv("SKIP_GO_BUILD") != "1" {
|
||||
process.RunOrExit(fmt.Sprintf("go build -o ./dist"))
|
||||
process.RunOrExit(process.NewRawCommand("", fmt.Sprintf("go build -tags prod -o ./dist")))
|
||||
}
|
||||
|
||||
fmt.Printf("Executable built at %s\n", process.GetPathRelativeToCwd("dist"))
|
||||
|
|
|
|||
|
|
@ -7,15 +7,15 @@ import (
|
|||
)
|
||||
|
||||
func EntNewSchema(name string) {
|
||||
process.RunOrExit("GOWORK=off go run -mod=mod entgo.io/ent/cmd/ent new " + name)
|
||||
process.RunOrExit(process.NewRawCommand("", "GOWORK=off go run -mod=mod entgo.io/ent/cmd/ent new "+name))
|
||||
}
|
||||
|
||||
func EntGenerate() {
|
||||
if dirutil.HasFileFromRoot("ent/schema") {
|
||||
if runtime.GOOS == "windows" {
|
||||
process.RunOrExit("go generate ./ent")
|
||||
process.RunOrExit(process.NewRawCommand("ent-generate", "go generate ./ent"))
|
||||
} else {
|
||||
process.RunOrExit("bash -c GOWORK=off go generate ./ent")
|
||||
process.RunOrExit(process.NewRawCommand("ent-generate", "bash -c GOWORK=off go generate ./ent"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,42 @@
|
|||
package run
|
||||
|
||||
import "github.com/maddalax/htmgo/cli/htmgo/tasks/process"
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func Server(flags ...process.RunFlag) error {
|
||||
return process.Run("go run .", flags...)
|
||||
buildDir := "./__htmgo/temp-build"
|
||||
_ = os.RemoveAll(buildDir)
|
||||
err := os.Mkdir(buildDir, 0755)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
process.RunOrExit(process.NewRawCommand("", fmt.Sprintf("go build -o %s", buildDir)))
|
||||
|
||||
binaryPath := ""
|
||||
|
||||
// find the binary that was built
|
||||
err = filepath.WalkDir(buildDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
binaryPath = path
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if binaryPath == "" {
|
||||
return fmt.Errorf("could not find the binary")
|
||||
}
|
||||
|
||||
return process.Run(process.NewRawCommand("run-server", fmt.Sprintf("./%s", binaryPath), flags...))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,12 @@
|
|||
package run
|
||||
|
||||
import (
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/astgen"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/copyassets"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/css"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
|
||||
)
|
||||
|
||||
func Setup() {
|
||||
process.RunOrExit("go mod download")
|
||||
process.RunOrExit("go mod tidy")
|
||||
|
||||
copyassets.CopyAssets()
|
||||
astgen.GenAst(process.ExitOnError)
|
||||
css.GenerateCss(process.ExitOnError)
|
||||
|
||||
process.RunOrExit(process.NewRawCommand("", "go mod download"))
|
||||
process.RunOrExit(process.NewRawCommand("", "go mod tidy"))
|
||||
MakeBuildable()
|
||||
EntGenerate()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ func ReplaceTextInFile(file string, text string, replacement string) error {
|
|||
|
||||
func ReplaceTextInDirRecursive(dir string, text string, replacement string, filter func(file string) bool) error {
|
||||
return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if filter(path) {
|
||||
if filter(filepath.Base(path)) {
|
||||
_ = ReplaceTextInFile(path, text, replacement)
|
||||
}
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -2,18 +2,22 @@ package main
|
|||
|
||||
import (
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/google/uuid"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/internal"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/internal/dirutil"
|
||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/module"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ignoredDirs = []string{".git", ".idea", "node_modules", "vendor"}
|
||||
|
||||
func startWatcher(cb func(file []*fsnotify.Event)) {
|
||||
func startWatcher(cb func(version string, file []*fsnotify.Event)) {
|
||||
events := make([]*fsnotify.Event, 0)
|
||||
debouncer := NewDebouncer(100 * time.Millisecond)
|
||||
debouncer := internal.NewDebouncer(500 * time.Millisecond)
|
||||
config := dirutil.GetConfig()
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
|
|
@ -34,45 +38,94 @@ func startWatcher(cb func(file []*fsnotify.Event)) {
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if event.Has(fsnotify.Remove) {
|
||||
if dirutil.IsGlobMatch(event.Name, config.WatchFiles, config.WatchIgnore) {
|
||||
watcher.Remove(event.Name)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if event.Has(fsnotify.Create) {
|
||||
if dirutil.IsGlobMatch(event.Name, config.WatchFiles, config.WatchIgnore) {
|
||||
watcher.Add(event.Name)
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(event.Name)
|
||||
if err != nil {
|
||||
slog.Error("Error getting file info:", slog.String("path", event.Name), slog.String("error", err.Error()))
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
err = watcher.Add(event.Name)
|
||||
if err != nil {
|
||||
slog.Error("Error adding directory to watcher:", slog.String("path", event.Name), slog.String("error", err.Error()))
|
||||
} else {
|
||||
slog.Debug("Watching directory:", slog.String("path", event.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if event.Has(fsnotify.Write) || event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) {
|
||||
if !dirutil.IsGlobMatch(event.Name, config.WatchFiles, config.WatchIgnore) {
|
||||
continue
|
||||
}
|
||||
events = append(events, &event)
|
||||
debouncer.Do(func() {
|
||||
cb(events)
|
||||
seen := make(map[string]bool)
|
||||
dedupe := make([]*fsnotify.Event, 0)
|
||||
for _, e := range events {
|
||||
if _, ok := seen[e.Name]; !ok {
|
||||
seen[e.Name] = true
|
||||
dedupe = append(dedupe, e)
|
||||
}
|
||||
}
|
||||
cb(uuid.NewString()[0:6], dedupe)
|
||||
events = make([]*fsnotify.Event, 0)
|
||||
})
|
||||
}
|
||||
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slog.Error("error:", err.Error())
|
||||
slog.Error("error:", slog.String("error", err.Error()))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
rootDir := "."
|
||||
|
||||
frameworkPath := module.GetDependencyPath("github.com/maddalax/htmgo/framework")
|
||||
|
||||
if !strings.HasPrefix(frameworkPath, "github.com/") {
|
||||
assetPath := filepath.Join(frameworkPath, "assets", "dist")
|
||||
slog.Debug("Watching directory:", slog.String("path", assetPath))
|
||||
watcher.Add(assetPath)
|
||||
}
|
||||
|
||||
// Walk through the root directory and add all subdirectories to the watcher
|
||||
err = filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Ignore directories in the ignoredDirs list
|
||||
for _, ignoredDir := range ignoredDirs {
|
||||
if ignoredDir == info.Name() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if dirutil.IsGlobExclude(path, config.WatchIgnore) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
// Only watch directories
|
||||
if info.IsDir() {
|
||||
err = watcher.Add(path)
|
||||
if err != nil {
|
||||
slog.Error("Error adding directory to watcher:", err)
|
||||
slog.Error("Error adding directory to watcher:", slog.String("error", err.Error()))
|
||||
} else {
|
||||
slog.Debug("Watching directory:", slog.String("path", path))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
|
|
|||
11
examples/chat/.dockerignore
Normal file
11
examples/chat/.dockerignore
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Project exclude paths
|
||||
/tmp/
|
||||
node_modules/
|
||||
dist/
|
||||
js/dist
|
||||
js/node_modules
|
||||
go.work
|
||||
go.work.sum
|
||||
.idea
|
||||
!framework/assets/dist
|
||||
__htmgo
|
||||
6
examples/chat/.gitignore
vendored
Normal file
6
examples/chat/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/assets/dist
|
||||
tmp
|
||||
node_modules
|
||||
.idea
|
||||
__htmgo
|
||||
dist
|
||||
36
examples/chat/Dockerfile
Normal file
36
examples/chat/Dockerfile
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Stage 1: Build the Go binary
|
||||
FROM golang:1.23 AS builder
|
||||
|
||||
# Set the working directory inside the container
|
||||
WORKDIR /app
|
||||
|
||||
# Copy go.mod and go.sum files
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# Download and cache the Go modules
|
||||
RUN go mod download
|
||||
|
||||
# Copy the source code into the container
|
||||
COPY . .
|
||||
|
||||
# Build the Go binary for Linux
|
||||
RUN CGO_ENABLED=0 GOPRIVATE=github.com/maddalax LOG_LEVEL=debug go run github.com/maddalax/htmgo/cli/htmgo@latest build
|
||||
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -tags prod -o ./dist -a -ldflags '-linkmode external -extldflags "-static"' .
|
||||
|
||||
|
||||
# Stage 2: Create the smallest possible image
|
||||
FROM gcr.io/distroless/base-debian11
|
||||
|
||||
# Set the working directory inside the container
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the Go binary from the builder stage
|
||||
COPY --from=builder /app/dist .
|
||||
|
||||
# Expose the necessary port (replace with your server port)
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
# Command to run the binary
|
||||
CMD ["./chat"]
|
||||
20
examples/chat/Taskfile.yml
Normal file
20
examples/chat/Taskfile.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
version: '3'
|
||||
|
||||
tasks:
|
||||
run:
|
||||
cmds:
|
||||
- go run github.com/maddalax/htmgo/cli/htmgo@latest run
|
||||
silent: true
|
||||
|
||||
build:
|
||||
cmds:
|
||||
- go run github.com/maddalax/htmgo/cli/htmgo@latest build
|
||||
|
||||
docker:
|
||||
cmds:
|
||||
- docker build .
|
||||
|
||||
watch:
|
||||
cmds:
|
||||
- go run github.com/maddalax/htmgo/cli/htmgo@latest watch
|
||||
silent: true
|
||||
13
examples/chat/assets.go
Normal file
13
examples/chat/assets.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
//go:build !prod
|
||||
// +build !prod
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"chat/internal/embedded"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
func GetStaticAssets() fs.FS {
|
||||
return embedded.NewOsFs()
|
||||
}
|
||||
16
examples/chat/assets_prod.go
Normal file
16
examples/chat/assets_prod.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
//go:build prod
|
||||
// +build prod
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed assets/dist/*
|
||||
var staticAssets embed.FS
|
||||
|
||||
func GetStaticAssets() fs.FS {
|
||||
return staticAssets
|
||||
}
|
||||
155
examples/chat/chat/broadcast.go
Normal file
155
examples/chat/chat/broadcast.go
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
package chat
|
||||
|
||||
import (
|
||||
"chat/internal/db"
|
||||
"chat/sse"
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
"github.com/maddalax/htmgo/framework/service"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
socketManager *sse.SocketManager
|
||||
queries *db.Queries
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewManager(locator *service.Locator) *Manager {
|
||||
return &Manager{
|
||||
socketManager: service.Get[sse.SocketManager](locator),
|
||||
queries: service.Get[db.Queries](locator),
|
||||
service: NewService(locator),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) StartListener() {
|
||||
c := make(chan sse.SocketEvent, 1)
|
||||
m.socketManager.Listen(c)
|
||||
|
||||
for {
|
||||
select {
|
||||
case event := <-c:
|
||||
switch event.Type {
|
||||
case sse.ConnectedEvent:
|
||||
m.OnConnected(event)
|
||||
case sse.DisconnectedEvent:
|
||||
m.OnDisconnected(event)
|
||||
case sse.MessageEvent:
|
||||
m.onMessage(event)
|
||||
default:
|
||||
fmt.Printf("Unknown event type: %s\n", event.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) dispatchConnectedUsers(roomId string, predicate func(conn sse.SocketConnection) bool) {
|
||||
|
||||
connectedUsers := make([]db.User, 0)
|
||||
|
||||
// backfill all existing clients to the connected client
|
||||
m.socketManager.ForEachSocket(roomId, func(conn sse.SocketConnection) {
|
||||
if !predicate(conn) {
|
||||
return
|
||||
}
|
||||
user, err := m.queries.GetUserBySessionId(context.Background(), conn.Id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
connectedUsers = append(connectedUsers, user)
|
||||
})
|
||||
|
||||
m.socketManager.ForEachSocket(roomId, func(conn sse.SocketConnection) {
|
||||
m.socketManager.SendText(conn.Id, h.Render(ConnectedUsers(connectedUsers, conn.Id)))
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) OnConnected(e sse.SocketEvent) {
|
||||
room, _ := m.service.GetRoom(e.RoomId)
|
||||
|
||||
if room == nil {
|
||||
m.socketManager.CloseWithMessage(e.Id, "invalid room")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := m.queries.GetUserBySessionId(context.Background(), e.Id)
|
||||
|
||||
if err != nil {
|
||||
m.socketManager.CloseWithMessage(e.Id, "invalid user")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("User %s connected to %s\n", user.Name, e.RoomId)
|
||||
|
||||
m.dispatchConnectedUsers(e.RoomId, func(conn sse.SocketConnection) bool {
|
||||
return true
|
||||
})
|
||||
|
||||
m.backFill(e.Id, e.RoomId)
|
||||
}
|
||||
|
||||
func (m *Manager) OnDisconnected(e sse.SocketEvent) {
|
||||
user, err := m.queries.GetUserBySessionId(context.Background(), e.Id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
room, err := m.service.GetRoom(e.RoomId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fmt.Printf("User %s disconnected from %s\n", user.Name, room.ID)
|
||||
m.dispatchConnectedUsers(e.RoomId, func(conn sse.SocketConnection) bool {
|
||||
return conn.Id != e.Id
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) backFill(socketId string, roomId string) {
|
||||
messages, _ := m.queries.GetLastMessages(context.Background(), db.GetLastMessagesParams{
|
||||
ChatRoomID: roomId,
|
||||
Limit: 200,
|
||||
})
|
||||
for _, message := range messages {
|
||||
parsed, _ := time.Parse("2006-01-02 15:04:05", message.CreatedAt)
|
||||
m.socketManager.SendText(socketId,
|
||||
h.Render(MessageRow(&Message{
|
||||
UserId: message.UserID,
|
||||
UserName: message.UserName,
|
||||
Message: message.Message,
|
||||
CreatedAt: parsed,
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) onMessage(e sse.SocketEvent) {
|
||||
message := e.Payload["message"].(string)
|
||||
|
||||
if message == "" {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := m.queries.GetUserBySessionId(context.Background(), e.Id)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Error getting user: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
saved := m.service.InsertMessage(
|
||||
&user,
|
||||
e.RoomId,
|
||||
message,
|
||||
)
|
||||
|
||||
if saved != nil {
|
||||
m.socketManager.BroadcastText(
|
||||
e.RoomId,
|
||||
h.Render(MessageRow(saved)),
|
||||
func(conn sse.SocketConnection) bool {
|
||||
return true
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
58
examples/chat/chat/component.go
Normal file
58
examples/chat/chat/component.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package chat
|
||||
|
||||
import (
|
||||
"chat/internal/db"
|
||||
"fmt"
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func MessageRow(message *Message) *h.Element {
|
||||
return h.Div(
|
||||
h.Attribute("hx-swap-oob", "beforeend"),
|
||||
h.Class("flex flex-col gap-4 w-full break-words whitespace-normal"),
|
||||
// Ensure container breaks long words
|
||||
h.Id("messages"),
|
||||
h.Div(
|
||||
h.Class("flex flex-col gap-1"),
|
||||
h.Div(
|
||||
h.Class("flex gap-2 items-center"),
|
||||
h.Pf(
|
||||
message.UserName,
|
||||
h.Class("font-bold"),
|
||||
),
|
||||
h.Pf(message.CreatedAt.In(time.Local).Format("01/02 03:04 PM")),
|
||||
),
|
||||
h.Article(
|
||||
h.Class("break-words whitespace-normal"),
|
||||
// Ensure message text wraps correctly
|
||||
h.P(
|
||||
h.Text(message.Message),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func ConnectedUsers(users []db.User, myId string) *h.Element {
|
||||
return h.Ul(
|
||||
h.Attribute("hx-swap-oob", "outerHTML"),
|
||||
h.Id("connected-users"),
|
||||
h.Class("flex flex-col"),
|
||||
h.List(users, func(user db.User, index int) *h.Element {
|
||||
return connectedUser(user.Name, user.SessionID == myId)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func connectedUser(username string, isMe bool) *h.Element {
|
||||
id := fmt.Sprintf("connected-user-%s", strings.ReplaceAll(username, "#", "-"))
|
||||
return h.Li(
|
||||
h.Id(id),
|
||||
h.ClassX("truncate text-slate-700", h.ClassMap{
|
||||
"font-bold": isMe,
|
||||
}),
|
||||
h.Text(username),
|
||||
)
|
||||
}
|
||||
84
examples/chat/chat/service.go
Normal file
84
examples/chat/chat/service.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package chat
|
||||
|
||||
import (
|
||||
"chat/internal/db"
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/maddalax/htmgo/framework/service"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
UserId int64 `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
Message string `json:"message"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
queries *db.Queries
|
||||
}
|
||||
|
||||
func NewService(locator *service.Locator) *Service {
|
||||
return &Service{
|
||||
queries: service.Get[db.Queries](locator),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) InsertMessage(user *db.User, roomId string, message string) *Message {
|
||||
err := s.queries.InsertMessage(context.Background(), db.InsertMessageParams{
|
||||
UserID: user.ID,
|
||||
Username: user.Name,
|
||||
ChatRoomID: roomId,
|
||||
Message: message,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to insert message: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
return &Message{
|
||||
UserId: user.ID,
|
||||
UserName: user.Name,
|
||||
Message: message,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) GetUserBySession(sessionId string) (*db.User, error) {
|
||||
user, err := s.queries.GetUserBySessionId(context.Background(), sessionId)
|
||||
return &user, err
|
||||
}
|
||||
|
||||
func (s *Service) CreateUser(name string) (*db.CreateUserRow, error) {
|
||||
nameWithHash := fmt.Sprintf("%s#%s", name, uuid.NewString()[0:4])
|
||||
sessionId := fmt.Sprintf("session-%s-%s", uuid.NewString(), uuid.NewString())
|
||||
user, err := s.queries.CreateUser(context.Background(), db.CreateUserParams{
|
||||
Name: nameWithHash,
|
||||
SessionID: sessionId,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateRoom(name string) (*db.CreateChatRoomRow, error) {
|
||||
room, err := s.queries.CreateChatRoom(context.Background(), db.CreateChatRoomParams{
|
||||
ID: fmt.Sprintf("room-%s-%s", uuid.NewString()[0:8], name),
|
||||
Name: name,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &room, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetRoom(id string) (*db.ChatRoom, error) {
|
||||
room, err := s.queries.GetChatRoom(context.Background(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &room, nil
|
||||
}
|
||||
57
examples/chat/components/button.go
Normal file
57
examples/chat/components/button.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package components
|
||||
|
||||
import "github.com/maddalax/htmgo/framework/h"
|
||||
|
||||
type ButtonProps struct {
|
||||
Id string
|
||||
Text string
|
||||
Target string
|
||||
Type string
|
||||
Trigger string
|
||||
Get string
|
||||
Class string
|
||||
Children []h.Ren
|
||||
}
|
||||
|
||||
func PrimaryButton(props ButtonProps) h.Ren {
|
||||
props.Class = h.MergeClasses(props.Class, "border-slate-800 bg-slate-900 hover:bg-slate-800 text-white")
|
||||
return Button(props)
|
||||
}
|
||||
|
||||
func SecondaryButton(props ButtonProps) h.Ren {
|
||||
props.Class = h.MergeClasses(props.Class, "border-gray-700 bg-gray-700 text-white")
|
||||
return Button(props)
|
||||
}
|
||||
|
||||
func Button(props ButtonProps) h.Ren {
|
||||
|
||||
text := h.Text(props.Text)
|
||||
|
||||
button := h.Button(
|
||||
h.If(
|
||||
props.Id != "",
|
||||
h.Id(props.Id),
|
||||
),
|
||||
h.If(
|
||||
props.Children != nil,
|
||||
h.Children(props.Children...),
|
||||
),
|
||||
h.Class("flex gap-1 items-center justify-center border p-4 rounded cursor-hover", props.Class),
|
||||
h.If(
|
||||
props.Get != "",
|
||||
h.Get(props.Get),
|
||||
),
|
||||
h.If(
|
||||
props.Target != "",
|
||||
h.HxTarget(props.Target),
|
||||
),
|
||||
h.IfElse(
|
||||
props.Type != "",
|
||||
h.Type(props.Type),
|
||||
h.Type("button"),
|
||||
),
|
||||
text,
|
||||
)
|
||||
|
||||
return button
|
||||
}
|
||||
14
examples/chat/components/error.go
Normal file
14
examples/chat/components/error.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package components
|
||||
|
||||
import "github.com/maddalax/htmgo/framework/h"
|
||||
|
||||
func FormError(error string) *h.Element {
|
||||
return h.Div(
|
||||
h.Id("form-error"),
|
||||
h.Text(error),
|
||||
h.If(
|
||||
error != "",
|
||||
h.Class("p-4 bg-rose-400 text-white rounded"),
|
||||
),
|
||||
)
|
||||
}
|
||||
81
examples/chat/components/input.go
Normal file
81
examples/chat/components/input.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
"github.com/maddalax/htmgo/framework/hx"
|
||||
)
|
||||
|
||||
type InputProps struct {
|
||||
Id string
|
||||
Label string
|
||||
Name string
|
||||
Type string
|
||||
DefaultValue string
|
||||
Placeholder string
|
||||
Required bool
|
||||
ValidationPath string
|
||||
Error string
|
||||
Children []h.Ren
|
||||
}
|
||||
|
||||
func Input(props InputProps) *h.Element {
|
||||
validation := h.If(
|
||||
props.ValidationPath != "",
|
||||
h.Children(
|
||||
h.Post(props.ValidationPath, hx.BlurEvent),
|
||||
h.Attribute("hx-swap", "innerHTML transition:true"),
|
||||
h.Attribute("hx-target", "next div"),
|
||||
),
|
||||
)
|
||||
|
||||
if props.Type == "" {
|
||||
props.Type = "text"
|
||||
}
|
||||
|
||||
input := h.Input(
|
||||
props.Type,
|
||||
h.Class("border p-2 rounded focus:outline-none focus:ring focus:ring-slate-800"),
|
||||
h.If(
|
||||
props.Name != "",
|
||||
h.Name(props.Name),
|
||||
),
|
||||
h.If(
|
||||
props.Children != nil,
|
||||
h.Children(props.Children...),
|
||||
),
|
||||
h.If(
|
||||
props.Required,
|
||||
h.Required(),
|
||||
),
|
||||
h.If(
|
||||
props.Placeholder != "",
|
||||
h.Placeholder(props.Placeholder),
|
||||
),
|
||||
h.If(
|
||||
props.DefaultValue != "",
|
||||
h.Attribute("value", props.DefaultValue),
|
||||
),
|
||||
validation,
|
||||
)
|
||||
|
||||
wrapped := h.Div(
|
||||
h.If(
|
||||
props.Id != "",
|
||||
h.Id(props.Id),
|
||||
),
|
||||
h.Class("flex flex-col gap-1"),
|
||||
h.If(
|
||||
props.Label != "",
|
||||
h.Label(
|
||||
h.Text(props.Label),
|
||||
),
|
||||
),
|
||||
input,
|
||||
h.Div(
|
||||
h.Id(props.Id+"-error"),
|
||||
h.Class("text-red-500"),
|
||||
),
|
||||
)
|
||||
|
||||
return wrapped
|
||||
}
|
||||
11
examples/chat/go.mod
Normal file
11
examples/chat/go.mod
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
module chat
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
|
||||
github.com/mattn/go-sqlite3 v1.14.23
|
||||
github.com/puzpuzpuz/xsync/v3 v3.4.0
|
||||
)
|
||||
20
examples/chat/go.sum
Normal file
20
examples/chat/go.sum
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4=
|
||||
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
|
||||
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
||||
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
||||
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
31
examples/chat/internal/db/db.go
Normal file
31
examples/chat/internal/db/db.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.27.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
|
||||
PrepareContext(context.Context, string) (*sql.Stmt, error)
|
||||
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
35
examples/chat/internal/db/models.go
Normal file
35
examples/chat/internal/db/models.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.27.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type ChatRoom struct {
|
||||
ID string
|
||||
Name string
|
||||
LastMessageSentAt sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
ID int64
|
||||
ChatRoomID string
|
||||
UserID int64
|
||||
Username string
|
||||
Message string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
Name string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
SessionID string
|
||||
}
|
||||
25
examples/chat/internal/db/provider.go
Normal file
25
examples/chat/internal/db/provider.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
//go:embed schema.sql
|
||||
var ddl string
|
||||
|
||||
func Provide() *Queries {
|
||||
db, err := sql.Open("sqlite3", "file:chat.db?cache=shared&_fk=1")
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(context.Background(), ddl); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return New(db)
|
||||
}
|
||||
47
examples/chat/internal/db/queries.sql
Normal file
47
examples/chat/internal/db/queries.sql
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
-- name: CreateChatRoom :one
|
||||
INSERT INTO chat_rooms (id, name, created_at, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
RETURNING id, name, created_at, updated_at, last_message_sent_at;
|
||||
|
||||
-- name: InsertMessage :exec
|
||||
INSERT INTO messages (chat_room_id, user_id, username, message, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
RETURNING id, chat_room_id, user_id, username, message, created_at, updated_at;
|
||||
|
||||
-- name: UpdateChatRoomLastMessageSentAt :exec
|
||||
UPDATE chat_rooms
|
||||
SET last_message_sent_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: GetChatRoom :one
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
last_message_sent_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM chat_rooms
|
||||
WHERE chat_rooms.id = ?;
|
||||
|
||||
-- name: CreateUser :one
|
||||
INSERT INTO users (name, session_id, created_at, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
RETURNING id, name, session_id, created_at, updated_at;
|
||||
|
||||
-- name: GetLastMessages :many
|
||||
SELECT
|
||||
messages.id,
|
||||
messages.chat_room_id,
|
||||
messages.user_id,
|
||||
users.name AS user_name,
|
||||
messages.message,
|
||||
messages.created_at,
|
||||
messages.updated_at
|
||||
FROM messages
|
||||
JOIN users ON messages.user_id = users.id
|
||||
WHERE messages.chat_room_id = ?
|
||||
ORDER BY messages.created_at
|
||||
LIMIT ?;
|
||||
|
||||
-- name: GetUserBySessionId :one
|
||||
SELECT * FROM users WHERE session_id = ?;
|
||||
212
examples/chat/internal/db/queries.sql.go
Normal file
212
examples/chat/internal/db/queries.sql.go
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.27.0
|
||||
// source: queries.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const createChatRoom = `-- name: CreateChatRoom :one
|
||||
INSERT INTO chat_rooms (id, name, created_at, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
RETURNING id, name, created_at, updated_at, last_message_sent_at
|
||||
`
|
||||
|
||||
type CreateChatRoomParams struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
type CreateChatRoomRow struct {
|
||||
ID string
|
||||
Name string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
LastMessageSentAt sql.NullString
|
||||
}
|
||||
|
||||
func (q *Queries) CreateChatRoom(ctx context.Context, arg CreateChatRoomParams) (CreateChatRoomRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, createChatRoom, arg.ID, arg.Name)
|
||||
var i CreateChatRoomRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LastMessageSentAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createUser = `-- name: CreateUser :one
|
||||
INSERT INTO users (name, session_id, created_at, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
RETURNING id, name, session_id, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
Name string
|
||||
SessionID string
|
||||
}
|
||||
|
||||
type CreateUserRow struct {
|
||||
ID int64
|
||||
Name string
|
||||
SessionID string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, createUser, arg.Name, arg.SessionID)
|
||||
var i CreateUserRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.SessionID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getChatRoom = `-- name: GetChatRoom :one
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
last_message_sent_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM chat_rooms
|
||||
WHERE chat_rooms.id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetChatRoom(ctx context.Context, id string) (ChatRoom, error) {
|
||||
row := q.db.QueryRowContext(ctx, getChatRoom, id)
|
||||
var i ChatRoom
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.LastMessageSentAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getLastMessages = `-- name: GetLastMessages :many
|
||||
SELECT
|
||||
messages.id,
|
||||
messages.chat_room_id,
|
||||
messages.user_id,
|
||||
users.name AS user_name,
|
||||
messages.message,
|
||||
messages.created_at,
|
||||
messages.updated_at
|
||||
FROM messages
|
||||
JOIN users ON messages.user_id = users.id
|
||||
WHERE messages.chat_room_id = ?
|
||||
ORDER BY messages.created_at
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
type GetLastMessagesParams struct {
|
||||
ChatRoomID string
|
||||
Limit int64
|
||||
}
|
||||
|
||||
type GetLastMessagesRow struct {
|
||||
ID int64
|
||||
ChatRoomID string
|
||||
UserID int64
|
||||
UserName string
|
||||
Message string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func (q *Queries) GetLastMessages(ctx context.Context, arg GetLastMessagesParams) ([]GetLastMessagesRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getLastMessages, arg.ChatRoomID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetLastMessagesRow
|
||||
for rows.Next() {
|
||||
var i GetLastMessagesRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ChatRoomID,
|
||||
&i.UserID,
|
||||
&i.UserName,
|
||||
&i.Message,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getUserBySessionId = `-- name: GetUserBySessionId :one
|
||||
SELECT id, name, created_at, updated_at, session_id FROM users WHERE session_id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserBySessionId(ctx context.Context, sessionID string) (User, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUserBySessionId, sessionID)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SessionID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertMessage = `-- name: InsertMessage :exec
|
||||
INSERT INTO messages (chat_room_id, user_id, username, message, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
RETURNING id, chat_room_id, user_id, username, message, created_at, updated_at
|
||||
`
|
||||
|
||||
type InsertMessageParams struct {
|
||||
ChatRoomID string
|
||||
UserID int64
|
||||
Username string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (q *Queries) InsertMessage(ctx context.Context, arg InsertMessageParams) error {
|
||||
_, err := q.db.ExecContext(ctx, insertMessage,
|
||||
arg.ChatRoomID,
|
||||
arg.UserID,
|
||||
arg.Username,
|
||||
arg.Message,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateChatRoomLastMessageSentAt = `-- name: UpdateChatRoomLastMessageSentAt :exec
|
||||
UPDATE chat_rooms
|
||||
SET last_message_sent_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) UpdateChatRoomLastMessageSentAt(ctx context.Context, id string) error {
|
||||
_, err := q.db.ExecContext(ctx, updateChatRoomLastMessageSentAt, id)
|
||||
return err
|
||||
}
|
||||
33
examples/chat/internal/db/schema.sql
Normal file
33
examples/chat/internal/db/schema.sql
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
CREATE TABLE IF NOT EXISTS users
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
session_id TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chat_rooms
|
||||
(
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
last_message_sent_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_room_id TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (chat_room_id) REFERENCES chat_rooms (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_chat_room_id ON messages (chat_room_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_user_id ON messages (user_id);
|
||||
17
examples/chat/internal/embedded/os.go
Normal file
17
examples/chat/internal/embedded/os.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package embedded
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
type OsFs struct {
|
||||
}
|
||||
|
||||
func (receiver OsFs) Open(name string) (fs.File, error) {
|
||||
return os.Open(name)
|
||||
}
|
||||
|
||||
func NewOsFs() OsFs {
|
||||
return OsFs{}
|
||||
}
|
||||
25
examples/chat/internal/routine/goroutine.go
Normal file
25
examples/chat/internal/routine/goroutine.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package routine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func DebugLongRunning(name string, f func()) {
|
||||
now := time.Now()
|
||||
done := make(chan struct{}, 1)
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Second * 5)
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
elapsed := time.Since(now).Milliseconds()
|
||||
fmt.Printf("function %s has not finished after %dms\n", name, elapsed)
|
||||
}
|
||||
}
|
||||
}()
|
||||
f()
|
||||
done <- struct{}{}
|
||||
}
|
||||
54
examples/chat/main.go
Normal file
54
examples/chat/main.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"chat/__htmgo"
|
||||
"chat/chat"
|
||||
"chat/internal/db"
|
||||
"chat/sse"
|
||||
"fmt"
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
"github.com/maddalax/htmgo/framework/service"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
locator := service.NewLocator()
|
||||
|
||||
service.Set[db.Queries](locator, service.Singleton, db.Provide)
|
||||
service.Set[sse.SocketManager](locator, service.Singleton, func() *sse.SocketManager {
|
||||
return sse.NewSocketManager()
|
||||
})
|
||||
|
||||
chatManager := chat.NewManager(locator)
|
||||
go chatManager.StartListener()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
count := runtime.NumGoroutine()
|
||||
fmt.Printf("goroutines: %d\n", count)
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
}()
|
||||
|
||||
h.Start(h.AppOpts{
|
||||
ServiceLocator: locator,
|
||||
LiveReload: true,
|
||||
Register: func(app *h.App) {
|
||||
sub, err := fs.Sub(GetStaticAssets(), "assets/dist")
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
http.FileServerFS(sub)
|
||||
|
||||
app.Router.Handle("/public/*", http.StripPrefix("/public", http.FileServerFS(sub)))
|
||||
app.Router.Handle("/sse/chat/{id}", sse.Handle())
|
||||
|
||||
__htmgo.Register(app.Router)
|
||||
},
|
||||
})
|
||||
}
|
||||
164
examples/chat/pages/chat.$id.go
Normal file
164
examples/chat/pages/chat.$id.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"chat/chat"
|
||||
"chat/internal/db"
|
||||
"chat/partials"
|
||||
"fmt"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
"github.com/maddalax/htmgo/framework/js"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ChatRoom(ctx *h.RequestContext) *h.Page {
|
||||
roomId := chi.URLParam(ctx.Request, "id")
|
||||
return h.NewPage(
|
||||
RootPage(
|
||||
h.Div(
|
||||
h.TriggerChildren(),
|
||||
h.Attribute("sse-connect", fmt.Sprintf("/sse/chat/%s", roomId)),
|
||||
h.HxOnSseOpen(
|
||||
js.ConsoleLog("Connected to chat room"),
|
||||
),
|
||||
h.HxOnSseError(
|
||||
js.EvalJs(fmt.Sprintf(`
|
||||
const reason = e.detail.event.data
|
||||
if(['invalid room', 'no session', 'invalid user'].includes(reason)) {
|
||||
window.location.href = '/?roomId=%s';
|
||||
} else if(e.detail.event.code === 1011) {
|
||||
window.location.reload()
|
||||
} else if (e.detail.event.code === 1008 || e.detail.event.code === 1006) {
|
||||
window.location.href = '/?roomId=%s';
|
||||
} else {
|
||||
console.error('Connection closed:', e.detail.event)
|
||||
}
|
||||
`, roomId, roomId)),
|
||||
),
|
||||
// Adjusted flex properties for responsive layout
|
||||
h.Class("flex flex-row h-screen bg-neutral-100 overflow-x-hidden"),
|
||||
// Collapse Button for mobile
|
||||
CollapseButton(),
|
||||
// Sidebar for connected users
|
||||
UserSidebar(),
|
||||
h.Div(
|
||||
// Adjusted to fill height and width
|
||||
h.Class("flex flex-col h-full w-full bg-white p-4 overflow-hidden"),
|
||||
// Room name at the top, fixed
|
||||
CachedRoomHeader(ctx),
|
||||
h.HxAfterSseMessage(
|
||||
js.EvalJsOnSibling("#messages",
|
||||
`element.scrollTop = element.scrollHeight;`),
|
||||
),
|
||||
// Chat Messages
|
||||
h.Div(
|
||||
h.Id("messages"),
|
||||
// Adjusted flex properties and removed max-width
|
||||
h.Class("flex flex-col gap-4 mb-4 overflow-auto flex-grow w-full pt-[50px]"),
|
||||
),
|
||||
// Chat Input at the bottom
|
||||
Form(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
var CachedRoomHeader = h.CachedPerKeyT(time.Hour, func(ctx *h.RequestContext) (string, h.GetElementFunc) {
|
||||
roomId := chi.URLParam(ctx.Request, "id")
|
||||
return roomId, func() *h.Element {
|
||||
return roomNameHeader(ctx)
|
||||
}
|
||||
})
|
||||
|
||||
func roomNameHeader(ctx *h.RequestContext) *h.Element {
|
||||
roomId := chi.URLParam(ctx.Request, "id")
|
||||
service := chat.NewService(ctx.ServiceLocator())
|
||||
room, err := service.GetRoom(roomId)
|
||||
if err != nil {
|
||||
return h.Div()
|
||||
}
|
||||
return h.Div(
|
||||
h.Class("bg-neutral-700 text-white p-3 shadow-sm w-full fixed top-0 left-0 flex justify-center z-10"),
|
||||
h.H2F(
|
||||
room.Name,
|
||||
h.Class("text-lg font-bold"),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("absolute right-5 top-3 cursor-pointer"),
|
||||
h.Text("Share"),
|
||||
h.OnClick(
|
||||
js.EvalJs(`
|
||||
alert("Share this url with your friends:\n " + window.location.href)
|
||||
`),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func UserSidebar() *h.Element {
|
||||
return h.Div(
|
||||
h.Class("sidebar h-full pt-[67px] min-w-48 w-48 bg-neutral-200 p-4 flex-col justify-between gap-3 rounded-l-lg hidden md:flex"),
|
||||
h.Div(
|
||||
h.H3F(
|
||||
"Connected Users",
|
||||
h.Class("text-lg font-bold"),
|
||||
),
|
||||
chat.ConnectedUsers(make([]db.User, 0), ""),
|
||||
),
|
||||
h.A(
|
||||
h.Class("cursor-pointer"),
|
||||
h.Href("/"),
|
||||
h.Text("Leave Room"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func CollapseButton() *h.Element {
|
||||
return h.Div(
|
||||
h.Class("fixed top-0 left-4 md:hidden z-50"),
|
||||
// Always visible on mobile
|
||||
h.Button(
|
||||
h.Class("p-2 text-2xl bg-neutral-700 text-white rounded-md"),
|
||||
// Styling the button
|
||||
h.OnClick(
|
||||
js.EvalJs(`
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
sidebar.classList.toggle('hidden');
|
||||
sidebar.classList.toggle('flex');
|
||||
`),
|
||||
),
|
||||
h.UnsafeRaw("☰"),
|
||||
|
||||
// The icon for collapsing the sidebar
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func MessageInput() *h.Element {
|
||||
return h.Input(
|
||||
"text",
|
||||
h.Id("message-input"),
|
||||
h.Required(),
|
||||
h.Class("p-4 rounded-md border border-slate-200 w-full focus:outline-none focus:ring focus:ring-slate-200"),
|
||||
h.Name("message"),
|
||||
h.MaxLength(1000),
|
||||
h.Placeholder("Type a message..."),
|
||||
h.HxAfterSseMessage(
|
||||
js.SetValue(""),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func Form() *h.Element {
|
||||
return h.Div(
|
||||
h.Class("flex gap-4 items-center"),
|
||||
h.Form(
|
||||
h.NoSwap(),
|
||||
h.PostPartial(partials.SendMessage),
|
||||
h.Attribute("ws-send", ""),
|
||||
h.Class("flex flex-grow"),
|
||||
MessageInput(),
|
||||
),
|
||||
)
|
||||
}
|
||||
87
examples/chat/pages/index.go
Normal file
87
examples/chat/pages/index.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"chat/components"
|
||||
"chat/partials"
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
)
|
||||
|
||||
func ChatAppFirstScreen(ctx *h.RequestContext) *h.Page {
|
||||
return h.NewPage(
|
||||
RootPage(
|
||||
h.Div(
|
||||
h.Class("flex flex-col items-center justify-center min-h-screen bg-neutral-100"),
|
||||
h.Div(
|
||||
h.Class("bg-white p-8 rounded-lg shadow-lg w-full max-w-md"),
|
||||
h.H2F(
|
||||
"htmgo chat",
|
||||
h.Class("text-3xl font-bold text-center mb-6"),
|
||||
),
|
||||
h.Form(
|
||||
h.Attribute("hx-swap", "none"),
|
||||
h.PostPartial(partials.CreateOrJoinRoom),
|
||||
h.Class("flex flex-col gap-6"),
|
||||
// Username input at the top
|
||||
components.Input(components.InputProps{
|
||||
Id: "username",
|
||||
Name: "username",
|
||||
Label: "Username",
|
||||
Required: true,
|
||||
Children: []h.Ren{
|
||||
h.Attribute("autocomplete", "off"),
|
||||
h.MaxLength(15),
|
||||
},
|
||||
}),
|
||||
// Single box for Create or Join a Chat Room
|
||||
h.Div(
|
||||
h.Class("p-4 border border-gray-300 rounded-md flex flex-col gap-6"),
|
||||
// Create New Chat Room input
|
||||
components.Input(components.InputProps{
|
||||
Name: "new-chat-room",
|
||||
Label: "Create a new chat room",
|
||||
Placeholder: "Enter chat room name",
|
||||
Children: []h.Ren{
|
||||
h.Attribute("autocomplete", "off"),
|
||||
h.MaxLength(20),
|
||||
},
|
||||
}),
|
||||
// OR divider
|
||||
h.Div(
|
||||
h.Class("flex items-center justify-center gap-4"),
|
||||
h.Div(
|
||||
h.Class("border-t border-gray-300 flex-grow"),
|
||||
),
|
||||
h.P(
|
||||
h.Text("OR"),
|
||||
h.Class("text-gray-500"),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("border-t border-gray-300 flex-grow"),
|
||||
),
|
||||
),
|
||||
// Join Chat Room input
|
||||
components.Input(components.InputProps{
|
||||
Id: "join-chat-room",
|
||||
Name: "join-chat-room",
|
||||
Label: "Join an existing chat room",
|
||||
Placeholder: "Enter chat room ID",
|
||||
DefaultValue: ctx.QueryParam("roomId"),
|
||||
Children: []h.Ren{
|
||||
h.Attribute("autocomplete", "off"),
|
||||
h.MaxLength(100),
|
||||
},
|
||||
}),
|
||||
),
|
||||
// Error message
|
||||
components.FormError(""),
|
||||
// Submit button at the bottom
|
||||
components.PrimaryButton(components.ButtonProps{
|
||||
Type: "submit",
|
||||
Text: "Submit",
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
26
examples/chat/pages/root.go
Normal file
26
examples/chat/pages/root.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
)
|
||||
|
||||
func RootPage(children ...h.Ren) h.Ren {
|
||||
extensions := h.BaseExtensions()
|
||||
return h.Html(
|
||||
h.HxExtension(extensions),
|
||||
h.Meta("viewport", "width=device-width, initial-scale=1"),
|
||||
h.Meta("title", "htmgo chat example"),
|
||||
h.Meta("charset", "utf-8"),
|
||||
h.Meta("author", "htmgo"),
|
||||
h.Head(
|
||||
h.Link("/public/main.css", "stylesheet"),
|
||||
h.Script("/public/htmgo.js"),
|
||||
),
|
||||
h.Body(
|
||||
h.Div(
|
||||
h.Class("flex flex-col gap-2 bg-white h-full"),
|
||||
h.Fragment(children...),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
35
examples/chat/partials/chat.go
Normal file
35
examples/chat/partials/chat.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package partials
|
||||
|
||||
import (
|
||||
"chat/components"
|
||||
"chat/sse"
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
"github.com/maddalax/htmgo/framework/service"
|
||||
)
|
||||
|
||||
func SendMessage(ctx *h.RequestContext) *h.Partial {
|
||||
locator := ctx.ServiceLocator()
|
||||
socketManager := service.Get[sse.SocketManager](locator)
|
||||
|
||||
sessionCookie, err := ctx.Request.Cookie("session_id")
|
||||
|
||||
if err != nil {
|
||||
return h.SwapPartial(ctx, components.FormError("Session not found"))
|
||||
}
|
||||
|
||||
message := ctx.Request.FormValue("message")
|
||||
|
||||
if message == "" {
|
||||
return h.SwapPartial(ctx, components.FormError("Message is required"))
|
||||
}
|
||||
|
||||
if len(message) > 200 {
|
||||
return h.SwapPartial(ctx, components.FormError("Message is too long"))
|
||||
}
|
||||
|
||||
socketManager.OnMessage(sessionCookie.Value, map[string]any{
|
||||
"message": message,
|
||||
})
|
||||
|
||||
return h.EmptyPartial()
|
||||
}
|
||||
73
examples/chat/partials/index.go
Normal file
73
examples/chat/partials/index.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package partials
|
||||
|
||||
import (
|
||||
"chat/chat"
|
||||
"chat/components"
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func CreateOrJoinRoom(ctx *h.RequestContext) *h.Partial {
|
||||
locator := ctx.ServiceLocator()
|
||||
service := chat.NewService(locator)
|
||||
|
||||
chatRoomId := ctx.Request.FormValue("join-chat-room")
|
||||
username := ctx.Request.FormValue("username")
|
||||
|
||||
if username == "" {
|
||||
return h.SwapPartial(ctx, components.FormError("Username is required"))
|
||||
}
|
||||
|
||||
if len(username) > 15 {
|
||||
return h.SwapPartial(ctx, components.FormError("Username is too long"))
|
||||
}
|
||||
|
||||
user, err := service.CreateUser(username)
|
||||
|
||||
if err != nil {
|
||||
return h.SwapPartial(ctx, components.FormError("Failed to create user"))
|
||||
}
|
||||
|
||||
var redirect = func(path string) *h.Partial {
|
||||
cookie := &http.Cookie{
|
||||
Name: "session_id",
|
||||
Value: user.SessionID,
|
||||
Path: "/",
|
||||
Expires: time.Now().Add(24 * 30 * time.Hour),
|
||||
}
|
||||
|
||||
return h.RedirectPartialWithHeaders(
|
||||
path,
|
||||
h.NewHeaders(
|
||||
"Set-Cookie", cookie.String(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if chatRoomId != "" {
|
||||
room, _ := service.GetRoom(chatRoomId)
|
||||
if room == nil {
|
||||
return h.SwapPartial(ctx, components.FormError("Room not found"))
|
||||
} else {
|
||||
return redirect("/chat/" + chatRoomId)
|
||||
}
|
||||
}
|
||||
|
||||
chatRoomName := ctx.Request.FormValue("new-chat-room")
|
||||
|
||||
if len(chatRoomName) > 20 {
|
||||
return h.SwapPartial(ctx, components.FormError("Chat room name is too long"))
|
||||
}
|
||||
|
||||
if chatRoomName != "" {
|
||||
room, _ := service.CreateRoom(chatRoomName)
|
||||
if room == nil {
|
||||
return h.SwapPartial(ctx, components.FormError("Failed to create room"))
|
||||
} else {
|
||||
return redirect("/chat/" + room.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return h.SwapPartial(ctx, components.FormError("Create a new room or join an existing one"))
|
||||
}
|
||||
9
examples/chat/sqlc.yaml
Normal file
9
examples/chat/sqlc.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
version: "2"
|
||||
sql:
|
||||
- schema: "internal/db/schema.sql"
|
||||
queries: "internal/db/queries.sql"
|
||||
engine: "sqlite"
|
||||
gen:
|
||||
go:
|
||||
package: "db"
|
||||
out: "internal/db"
|
||||
111
examples/chat/sse/handler.go
Normal file
111
examples/chat/sse/handler.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
package sse
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
"github.com/maddalax/htmgo/framework/service"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Handle() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Set the necessary headers
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*") // Optional for CORS
|
||||
|
||||
cc := r.Context().Value(h.RequestContextKey).(*h.RequestContext)
|
||||
locator := cc.ServiceLocator()
|
||||
manager := service.Get[SocketManager](locator)
|
||||
|
||||
sessionCookie, _ := r.Cookie("session_id")
|
||||
sessionId := ""
|
||||
|
||||
if sessionCookie != nil {
|
||||
sessionId = sessionCookie.Value
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
/*
|
||||
Large buffer in case the client disconnects while we are writing
|
||||
we don't want to block the writer
|
||||
*/
|
||||
done := make(chan bool, 1000)
|
||||
writer := make(WriterChan, 1000)
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(1)
|
||||
|
||||
/*
|
||||
* This goroutine is responsible for writing messages to the client
|
||||
*/
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer manager.Disconnect(sessionId)
|
||||
|
||||
defer func() {
|
||||
for len(writer) > 0 {
|
||||
<-writer
|
||||
}
|
||||
for len(done) > 0 {
|
||||
<-done
|
||||
}
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-done:
|
||||
fmt.Printf("closing connection: \n")
|
||||
return
|
||||
case <-ticker.C:
|
||||
manager.Ping(sessionId)
|
||||
case message := <-writer:
|
||||
_, err := fmt.Fprintf(w, message)
|
||||
if err != nil {
|
||||
done <- true
|
||||
} else {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
/**
|
||||
* This goroutine is responsible for adding the client to the room
|
||||
*/
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if sessionId == "" {
|
||||
manager.writeCloseRaw(writer, "no session")
|
||||
return
|
||||
}
|
||||
|
||||
roomId := chi.URLParam(r, "id")
|
||||
|
||||
if roomId == "" {
|
||||
slog.Error("invalid room", slog.String("room_id", roomId))
|
||||
manager.writeCloseRaw(writer, "invalid room")
|
||||
return
|
||||
}
|
||||
|
||||
manager.Add(roomId, sessionId, writer, done)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
231
examples/chat/sse/manager.go
Normal file
231
examples/chat/sse/manager.go
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
package sse
|
||||
|
||||
import (
|
||||
"chat/internal/routine"
|
||||
"fmt"
|
||||
"github.com/puzpuzpuz/xsync/v3"
|
||||
"time"
|
||||
)
|
||||
|
||||
type EventType string
|
||||
type WriterChan chan string
|
||||
type DoneChan chan bool
|
||||
|
||||
const (
|
||||
ConnectedEvent EventType = "connected"
|
||||
DisconnectedEvent EventType = "disconnected"
|
||||
MessageEvent EventType = "message"
|
||||
)
|
||||
|
||||
type SocketEvent struct {
|
||||
Id string
|
||||
RoomId string
|
||||
Type EventType
|
||||
Payload map[string]any
|
||||
}
|
||||
|
||||
type CloseEvent struct {
|
||||
Code int
|
||||
Reason string
|
||||
}
|
||||
|
||||
type SocketConnection struct {
|
||||
Id string
|
||||
RoomId string
|
||||
Done DoneChan
|
||||
Writer WriterChan
|
||||
}
|
||||
|
||||
type SocketManager struct {
|
||||
sockets *xsync.MapOf[string, *xsync.MapOf[string, SocketConnection]]
|
||||
idToRoom *xsync.MapOf[string, string]
|
||||
listeners []chan SocketEvent
|
||||
}
|
||||
|
||||
func NewSocketManager() *SocketManager {
|
||||
return &SocketManager{
|
||||
sockets: xsync.NewMapOf[string, *xsync.MapOf[string, SocketConnection]](),
|
||||
idToRoom: xsync.NewMapOf[string, string](),
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *SocketManager) ForEachSocket(roomId string, cb func(conn SocketConnection)) {
|
||||
sockets, ok := manager.sockets.Load(roomId)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sockets.Range(func(id string, conn SocketConnection) bool {
|
||||
cb(conn)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *SocketManager) Listen(listener chan SocketEvent) {
|
||||
if manager.listeners == nil {
|
||||
manager.listeners = make([]chan SocketEvent, 0)
|
||||
}
|
||||
if listener != nil {
|
||||
manager.listeners = append(manager.listeners, listener)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *SocketManager) dispatch(event SocketEvent) {
|
||||
done := make(chan struct{}, 1)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-time.After(5 * time.Second):
|
||||
fmt.Printf("havent dispatched listener event after 5s, chan blocked: %s\n", event.Type)
|
||||
}
|
||||
}
|
||||
}()
|
||||
for _, listener := range manager.listeners {
|
||||
listener <- event
|
||||
}
|
||||
done <- struct{}{}
|
||||
}
|
||||
|
||||
func (manager *SocketManager) OnMessage(id string, message map[string]any) {
|
||||
socket := manager.Get(id)
|
||||
if socket == nil {
|
||||
return
|
||||
}
|
||||
manager.dispatch(SocketEvent{
|
||||
Id: id,
|
||||
Type: MessageEvent,
|
||||
Payload: message,
|
||||
RoomId: socket.RoomId,
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *SocketManager) Add(roomId string, id string, writer WriterChan, done DoneChan) {
|
||||
manager.idToRoom.Store(id, roomId)
|
||||
|
||||
sockets, ok := manager.sockets.LoadOrCompute(roomId, func() *xsync.MapOf[string, SocketConnection] {
|
||||
return xsync.NewMapOf[string, SocketConnection]()
|
||||
})
|
||||
|
||||
sockets.Store(id, SocketConnection{
|
||||
Id: id,
|
||||
Writer: writer,
|
||||
RoomId: roomId,
|
||||
Done: done,
|
||||
})
|
||||
|
||||
s, ok := sockets.Load(id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
manager.dispatch(SocketEvent{
|
||||
Id: s.Id,
|
||||
Type: ConnectedEvent,
|
||||
RoomId: s.RoomId,
|
||||
Payload: map[string]any{},
|
||||
})
|
||||
|
||||
fmt.Printf("User %s connected to %s\n", id, roomId)
|
||||
}
|
||||
|
||||
func (manager *SocketManager) OnClose(id string) {
|
||||
socket := manager.Get(id)
|
||||
if socket == nil {
|
||||
return
|
||||
}
|
||||
manager.dispatch(SocketEvent{
|
||||
Id: id,
|
||||
Type: DisconnectedEvent,
|
||||
RoomId: socket.RoomId,
|
||||
Payload: map[string]any{},
|
||||
})
|
||||
manager.sockets.Delete(id)
|
||||
}
|
||||
|
||||
func (manager *SocketManager) CloseWithMessage(id string, message string) {
|
||||
conn := manager.Get(id)
|
||||
if conn != nil {
|
||||
defer manager.OnClose(id)
|
||||
manager.writeText(*conn, "error", message)
|
||||
conn.Done <- true
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *SocketManager) Disconnect(id string) {
|
||||
conn := manager.Get(id)
|
||||
if conn != nil {
|
||||
manager.OnClose(id)
|
||||
conn.Done <- true
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *SocketManager) Get(id string) *SocketConnection {
|
||||
roomId, ok := manager.idToRoom.Load(id)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
sockets, ok := manager.sockets.Load(roomId)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
conn, ok := sockets.Load(id)
|
||||
return &conn
|
||||
}
|
||||
|
||||
func (manager *SocketManager) Ping(id string) {
|
||||
conn := manager.Get(id)
|
||||
if conn != nil {
|
||||
manager.writeText(*conn, "ping", "")
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *SocketManager) writeCloseRaw(writer WriterChan, message string) {
|
||||
manager.writeTextRaw(writer, "close", message)
|
||||
}
|
||||
|
||||
func (manager *SocketManager) writeTextRaw(writer WriterChan, event string, message string) {
|
||||
routine.DebugLongRunning("writeTextRaw", func() {
|
||||
timeout := 3 * time.Second
|
||||
data := ""
|
||||
if event != "" {
|
||||
data = fmt.Sprintf("event: %s\ndata: %s\n\n", event, message)
|
||||
} else {
|
||||
data = fmt.Sprintf("data: %s\n\n", message)
|
||||
}
|
||||
select {
|
||||
case writer <- data:
|
||||
case <-time.After(timeout):
|
||||
fmt.Printf("could not send %s to channel after %s\n", data, timeout)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *SocketManager) writeText(socket SocketConnection, event string, message string) {
|
||||
if socket.Writer == nil {
|
||||
return
|
||||
}
|
||||
manager.writeTextRaw(socket.Writer, event, message)
|
||||
}
|
||||
|
||||
func (manager *SocketManager) BroadcastText(roomId string, message string, predicate func(conn SocketConnection) bool) {
|
||||
sockets, ok := manager.sockets.Load(roomId)
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
sockets.Range(func(id string, conn SocketConnection) bool {
|
||||
if predicate(conn) {
|
||||
manager.writeText(conn, "", message)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *SocketManager) SendText(id string, message string) {
|
||||
conn := manager.Get(id)
|
||||
if conn != nil {
|
||||
manager.writeText(*conn, "", message)
|
||||
}
|
||||
}
|
||||
5
examples/chat/tailwind.config.js
Normal file
5
examples/chat/tailwind.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["**/*.go"],
|
||||
plugins: [],
|
||||
};
|
||||
11
examples/hackernews/.dockerignore
Normal file
11
examples/hackernews/.dockerignore
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Project exclude paths
|
||||
/tmp/
|
||||
node_modules/
|
||||
dist/
|
||||
js/dist
|
||||
js/node_modules
|
||||
go.work
|
||||
go.work.sum
|
||||
.idea
|
||||
!framework/assets/dist
|
||||
__htmgo
|
||||
6
examples/hackernews/.gitignore
vendored
Normal file
6
examples/hackernews/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/assets/dist
|
||||
tmp
|
||||
node_modules
|
||||
.idea
|
||||
__htmgo
|
||||
dist
|
||||
38
examples/hackernews/Dockerfile
Normal file
38
examples/hackernews/Dockerfile
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Stage 1: Build the Go binary
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
RUN apk update
|
||||
RUN apk add git
|
||||
RUN apk add curl
|
||||
|
||||
# Set the working directory inside the container
|
||||
WORKDIR /app
|
||||
|
||||
# Copy go.mod and go.sum files
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# Download and cache the Go modules
|
||||
RUN go mod download
|
||||
|
||||
# Copy the source code into the container
|
||||
COPY . .
|
||||
|
||||
# Build the Go binary for Linux
|
||||
RUN GOPRIVATE=github.com/maddalax GOPROXY=direct go run github.com/maddalax/htmgo/cli/htmgo@latest build
|
||||
|
||||
|
||||
# Stage 2: Create the smallest possible image
|
||||
FROM gcr.io/distroless/base-debian11
|
||||
|
||||
# Set the working directory inside the container
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the Go binary from the builder stage
|
||||
COPY --from=builder /app/dist .
|
||||
|
||||
# Expose the necessary port (replace with your server port)
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
# Command to run the binary
|
||||
CMD ["./hackernews"]
|
||||
20
examples/hackernews/Taskfile.yml
Normal file
20
examples/hackernews/Taskfile.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
version: '3'
|
||||
|
||||
tasks:
|
||||
run:
|
||||
cmds:
|
||||
- htmgo run
|
||||
silent: true
|
||||
|
||||
build:
|
||||
cmds:
|
||||
- htmgo build
|
||||
|
||||
docker:
|
||||
cmds:
|
||||
- docker build .
|
||||
|
||||
watch:
|
||||
cmds:
|
||||
- htmgo watch
|
||||
silent: true
|
||||
13
examples/hackernews/assets.go
Normal file
13
examples/hackernews/assets.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
//go:build !prod
|
||||
// +build !prod
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"hackernews/internal/embedded"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
func GetStaticAssets() fs.FS {
|
||||
return embedded.NewOsFs()
|
||||
}
|
||||
15
examples/hackernews/assets/css/input.css
Normal file
15
examples/hackernews/assets/css/input.css
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer utilities {
|
||||
/* Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
}
|
||||
BIN
examples/hackernews/assets/public/apple-touch-icon.png
Normal file
BIN
examples/hackernews/assets/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
BIN
examples/hackernews/assets/public/favicon.ico
Normal file
BIN
examples/hackernews/assets/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
BIN
examples/hackernews/assets/public/icon-192-maskable.png
Normal file
BIN
examples/hackernews/assets/public/icon-192-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
BIN
examples/hackernews/assets/public/icon-192.png
Normal file
BIN
examples/hackernews/assets/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
BIN
examples/hackernews/assets/public/icon-512-maskable.png
Normal file
BIN
examples/hackernews/assets/public/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
examples/hackernews/assets/public/icon-512.png
Normal file
BIN
examples/hackernews/assets/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
16
examples/hackernews/assets_prod.go
Normal file
16
examples/hackernews/assets_prod.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
//go:build prod
|
||||
// +build prod
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed assets/dist/*
|
||||
var staticAssets embed.FS
|
||||
|
||||
func GetStaticAssets() fs.FS {
|
||||
return staticAssets
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue